feat(web): migrate classroom reservations
This commit is contained in:
1 parent
c5b6954f9c
commit
8e24bc13b6
6 files changed
+132
No files matched your search
@@ -50,6 +50,7 @@ const TeachingTasksPage = lazy(() => import('../pages/TeachingTasksPage'))
|
||||
const SchedulesPage = lazy(() => import('../pages/SchedulesPage'))
|
||||
const TimetableQueryPage = lazy(() => import('../pages/TimetableQueryPage'))
|
||||
const CourseAdjustmentsPage = lazy(() => import('../pages/CourseAdjustmentsPage'))
|
||||
const ClassroomReservationsPage = lazy(() => import('../pages/ClassroomReservationsPage'))
|
||||
const AccountActivationPage = lazy(() => import('../pages/AccountActivationPage'))
|
||||
const OfficialDocumentVerifyPage = lazy(() => import('../pages/OfficialDocumentVerifyPage'))
|
||||
const ClassroomRepairReportPage = lazy(() => import('../pages/ClassroomRepairReportPage'))
|
||||
@@ -148,6 +149,7 @@ function AppRoutes() {
|
||||
<Route path="/class-timetable" element={<TimetableQueryPage />} />
|
||||
<Route path="/teacher-timetable/:teacherId" element={<TimetableQueryPage />} />
|
||||
<Route path="/course-adjustments" element={<CourseAdjustmentsPage />} />
|
||||
<Route path="/classroom-reservations" element={<ClassroomReservationsPage />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
|
||||
@@ -73,6 +73,7 @@ export default function AppShell() {
|
||||
...((roles.includes('Student') || roles.includes('Teacher'))
|
||||
? [{ key: '/my-timetable', icon: <CalendarOutlined />, label: '我的课表' }]
|
||||
: []),
|
||||
{ key: '/classroom-reservations', icon: <EnvironmentOutlined />, label: roles.includes('CollegeAdmin') ? '教室借用审核' : '教室借用' },
|
||||
...((roles.includes('Student') || roles.includes('Teacher'))
|
||||
? [{ key: '/exams', icon: <FileDoneOutlined />, label: roles.includes('Teacher') ? '监考安排' : '考试安排' }]
|
||||
: []),
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { CheckOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'
|
||||
import { Alert, App, Button, Empty, Form, Input, InputNumber, Modal, Pagination, Select, Skeleton, Table, Tabs, Tag } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
import PageHeader from '../components/PageHeader'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import './classroom-reservations.css'
|
||||
|
||||
type Status = 'Submitted' | 'Approved' | 'Rejected' | 'Cancelled'
|
||||
interface Applicant { id: string; displayName: string; collegeId: string; collegeName: string }
|
||||
interface Term { id: string; name: string; startDate: string; endDate: string; isCurrent: boolean; hasPublishedTimetable: boolean }
|
||||
interface Campus { id: string; code: string; name: string }
|
||||
interface Building { id: string; code: string; name: string; campusId: string }
|
||||
interface TimeSlot { periodNumber: number; name: string; startTime: string; endTime: string }
|
||||
interface Room { id: string; code: string; name: string; buildingId: string; capacity: number; roomType: string; buildingName?: string; campusId?: string; campusName?: string }
|
||||
interface Options { applicant?: Applicant; applicantProblem?: string; terms: Term[]; selectedTermId?: string; campuses: Campus[]; buildings: Building[]; classrooms: Room[]; timeSlots: TimeSlot[] }
|
||||
interface Reservation { id: string; applicantUserId: string; applicantName: string; applicantCollegeId: string; applicantCollegeName: string; academicTermId: string; academicTermName: string; classroomId: string; classroomCode: string; classroomName: string; buildingName: string; campusName: string; reservationDate: string; startPeriod: number; periodCount: number; attendeeCount: number; purpose: string; contactPhone: string; notes?: string; status: Status; reviewComment?: string; reviewedAt?: string; cancelledAt?: string; createdAt: string }
|
||||
interface Page { items: Reservation[]; total: number; page: number; pageSize: number }
|
||||
interface ReservationForm { academicTermId: string; reservationDate: string; campusId?: string; buildingId?: string; classroomId?: string; startPeriod: number; periodCount: number; attendeeCount: number; purpose: string; contactPhone: string; notes?: string }
|
||||
|
||||
const labels: Record<Status, string> = { Submitted: '待学院审核', Approved: '已批准', Rejected: '已驳回', Cancelled: '已取消' }
|
||||
const colors: Record<Status, string> = { Submitted: 'orange', Approved: 'green', Rejected: 'red', Cancelled: 'default' }
|
||||
const chinaToday = () => new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date())
|
||||
const periodText = (row: Pick<Reservation, 'startPeriod' | 'periodCount'>) => row.periodCount === 1 ? `第 ${row.startPeriod} 节` : `第 ${row.startPeriod}—${row.startPeriod + row.periodCount - 1} 节`
|
||||
|
||||
function ReservationTable({ items, loading, reviewing, busyId, onCancel, onApprove, onReject }: { items: Reservation[]; loading: boolean; reviewing?: boolean; busyId?: string; onCancel: (item: Reservation) => void; onApprove: (item: Reservation) => void; onReject: (item: Reservation) => void }) {
|
||||
const columns: ColumnsType<Reservation> = reviewing ? [
|
||||
{ title: '申请人', width: 140, render: (_, row) => <div className="reservation-cell"><strong>{row.applicantName}</strong><span>{row.applicantCollegeName}</span></div> },
|
||||
{ title: '日期与教室', width: 230, render: (_, row) => <div className="reservation-cell"><strong>{row.reservationDate} · {periodText(row)}</strong><span>{row.campusName} · {row.buildingName} {row.classroomName}</span></div> },
|
||||
{ title: '用途与人数', render: (_, row) => <div className="reservation-cell"><strong>{row.purpose}</strong><span>{row.attendeeCount} 人 · {row.contactPhone}</span></div> },
|
||||
{ title: '提交时间', width: 180, render: (_, row) => new Date(row.createdAt).toLocaleString('zh-CN', { hour12: false }) },
|
||||
{ title: '状态', width: 110, render: (_, row) => <Tag color={colors[row.status]}>{labels[row.status]}</Tag> },
|
||||
{ title: '操作', width: 150, fixed: 'right', render: (_, row) => row.status === 'Submitted' ? <div className="reservation-actions"><Button type="link" loading={busyId === row.id} onClick={() => onApprove(row)}>批准</Button><Button danger type="link" disabled={busyId === row.id} onClick={() => onReject(row)}>驳回</Button></div> : <span className="muted">{row.reviewComment || '—'}</span> },
|
||||
] : [
|
||||
{ title: '日期与节次', width: 170, render: (_, row) => <div className="reservation-cell"><strong>{row.reservationDate}</strong><span>{periodText(row)}</span></div> },
|
||||
{ title: '教室', width: 205, render: (_, row) => <div className="reservation-cell"><strong>{row.buildingName} · {row.classroomName}</strong><span>{row.campusName} · {row.attendeeCount} 人</span></div> },
|
||||
{ title: '用途', dataIndex: 'purpose' },
|
||||
{ title: '状态', width: 120, render: (_, row) => <Tag color={colors[row.status]}>{labels[row.status]}</Tag> },
|
||||
{ title: '审核意见', width: 210, render: (_, row) => row.reviewComment || '—' },
|
||||
{ title: '操作', width: 100, fixed: 'right', render: (_, row) => ['Submitted', 'Approved'].includes(row.status) ? <Button danger type="link" loading={busyId === row.id} onClick={() => onCancel(row)}>取消</Button> : '—' },
|
||||
]
|
||||
return <Table<Reservation> rowKey="id" loading={loading} columns={columns} dataSource={items} pagination={false} scroll={{ x: reviewing ? 980 : 860 }} locale={{ emptyText: <Empty description={reviewing ? '当前筛选下没有申请' : '还没有教室借用申请'} /> }} />
|
||||
}
|
||||
|
||||
export default function ClassroomReservationsPage() {
|
||||
const { message, modal } = App.useApp()
|
||||
const user = useAuthStore((state) => state.user)
|
||||
const isCollegeAdmin = user?.roles.includes('CollegeAdmin') ?? false
|
||||
const [tab, setTab] = useState<'mine' | 'review'>('mine')
|
||||
const [optionTermId, setOptionTermId] = useState<string>()
|
||||
const [minePage, setMinePage] = useState(1)
|
||||
const [reviewPage, setReviewPage] = useState(1)
|
||||
const [reviewStatus, setReviewStatus] = useState<Status | undefined>('Submitted')
|
||||
const [availableRooms, setAvailableRooms] = useState<Room[]>([])
|
||||
const [availabilityChecked, setAvailabilityChecked] = useState(false)
|
||||
const [availabilityLoading, setAvailabilityLoading] = useState(false)
|
||||
const [busyId, setBusyId] = useState<string>()
|
||||
const [rejecting, setRejecting] = useState<Reservation>()
|
||||
const [rejectComment, setRejectComment] = useState('')
|
||||
const [form] = Form.useForm<ReservationForm>()
|
||||
const termId = Form.useWatch('academicTermId', form)
|
||||
const campusId = Form.useWatch('campusId', form)
|
||||
const startPeriod = Form.useWatch('startPeriod', form) ?? 1
|
||||
|
||||
const options = useQuery({ queryKey: ['classroom-reservation-options', optionTermId], queryFn: async () => (await http.get<Options>('/classroom-reservations/options', { params: optionTermId ? { academicTermId: optionTermId } : undefined })).data })
|
||||
const mine = useQuery({ queryKey: ['classroom-reservations-mine', minePage], queryFn: async () => (await http.get<Page>('/classroom-reservations/mine', { params: { page: minePage, pageSize: 12 } })).data })
|
||||
const review = useQuery({ queryKey: ['classroom-reservations-review', reviewStatus, reviewPage], enabled: isCollegeAdmin && tab === 'review', queryFn: async () => (await http.get<Page>('/classroom-reservations/review', { params: { status: reviewStatus, page: reviewPage, pageSize: 12 } })).data })
|
||||
const selectedTerm = options.data?.terms.find((term) => term.id === termId)
|
||||
const buildings = useMemo(() => (options.data?.buildings ?? []).filter((building) => !campusId || building.campusId === campusId), [options.data?.buildings, campusId])
|
||||
const periodCounts = useMemo(() => { const enabled = new Set((options.data?.timeSlots ?? []).map((item) => item.periodNumber)); const result: number[] = []; for (let count = 1; count <= 6 && enabled.has(startPeriod + count - 1); count += 1) result.push(count); return result }, [options.data?.timeSlots, startPeriod])
|
||||
const today = chinaToday()
|
||||
|
||||
useEffect(() => {
|
||||
if (!options.data) return
|
||||
const nextTerm = options.data.selectedTermId ?? options.data.terms.find((item) => item.hasPublishedTimetable)?.id
|
||||
const term = options.data.terms.find((item) => item.id === nextTerm)
|
||||
const current = form.getFieldsValue()
|
||||
const date = current.reservationDate && term && current.reservationDate >= term.startDate && current.reservationDate <= term.endDate ? current.reservationDate : (term ? (today < term.startDate ? term.startDate : today > term.endDate ? '' : today) : '')
|
||||
form.setFieldsValue({ academicTermId: nextTerm, reservationDate: date, startPeriod: options.data.timeSlots.some((item) => item.periodNumber === current.startPeriod) ? current.startPeriod : options.data.timeSlots[0]?.periodNumber ?? 1, periodCount: current.periodCount ?? 2, attendeeCount: current.attendeeCount ?? 1 })
|
||||
setAvailableRooms([]); setAvailabilityChecked(false)
|
||||
}, [options.data]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
function invalidateAvailability() { setAvailabilityChecked(false); setAvailableRooms([]); form.setFieldValue('classroomId', undefined) }
|
||||
function onValuesChange(changed: Partial<ReservationForm>) {
|
||||
const invalidating = ['reservationDate', 'startPeriod', 'periodCount', 'attendeeCount', 'campusId', 'buildingId'].some((key) => key in changed)
|
||||
if ('campusId' in changed && form.getFieldValue('buildingId') && !buildings.some((item) => item.id === form.getFieldValue('buildingId'))) form.setFieldValue('buildingId', undefined)
|
||||
if ('startPeriod' in changed) {
|
||||
const enabled = new Set((options.data?.timeSlots ?? []).map((item) => item.periodNumber))
|
||||
const nextCounts = Array.from({ length: 6 }, (_, index) => index + 1).filter((count) => Array.from({ length: count }, (_, index) => index).every((offset) => enabled.has((changed.startPeriod ?? startPeriod) + offset)))
|
||||
if (!nextCounts.includes(form.getFieldValue('periodCount'))) form.setFieldValue('periodCount', nextCounts[0] ?? 1)
|
||||
}
|
||||
if (invalidating) invalidateAvailability()
|
||||
}
|
||||
async function searchRooms() {
|
||||
try {
|
||||
await form.validateFields(['academicTermId', 'reservationDate', 'startPeriod', 'periodCount', 'attendeeCount'])
|
||||
const values = form.getFieldsValue()
|
||||
setAvailabilityLoading(true)
|
||||
const result = await http.get<{ items: Room[] }>('/classroom-reservations/availability', { params: { academicTermId: values.academicTermId, reservationDate: values.reservationDate, startPeriod: values.startPeriod, periodCount: values.periodCount, attendeeCount: values.attendeeCount, campusId: values.campusId, buildingId: values.buildingId } })
|
||||
setAvailableRooms(result.data.items); setAvailabilityChecked(true)
|
||||
if (!result.data.items.length) message.warning('当前条件下没有可预约教室,请调整日期、节次或人数。')
|
||||
} catch (error) { if (error && typeof error === 'object' && 'errorFields' in error) return; message.error(apiErrorMessage(error)) } finally { setAvailabilityLoading(false) }
|
||||
}
|
||||
async function submit(values: ReservationForm) {
|
||||
if (!availabilityChecked || !values.classroomId) { message.warning('请先查询可用教室并选择教室。'); return }
|
||||
try { setBusyId('create'); await http.post('/classroom-reservations', { academicTermId: values.academicTermId, classroomId: values.classroomId, reservationDate: values.reservationDate, startPeriod: values.startPeriod, periodCount: values.periodCount, attendeeCount: values.attendeeCount, purpose: values.purpose.trim(), contactPhone: values.contactPhone.trim(), notes: values.notes?.trim() || null }); message.success('申请已提交,将由所在学院审核。'); form.setFieldsValue({ purpose: '', notes: '', classroomId: undefined }); invalidateAvailability(); setTab('mine'); setMinePage(1); await mine.refetch() } catch (error) { message.error(apiErrorMessage(error)) } finally { setBusyId(undefined) }
|
||||
}
|
||||
async function refresh() { await Promise.all([options.refetch(), mine.refetch(), isCollegeAdmin ? review.refetch() : Promise.resolve()]) }
|
||||
async function cancel(item: Reservation) { try { setBusyId(item.id); await http.post(`/classroom-reservations/${item.id}/cancel`); message.success('预约已取消。'); await mine.refetch() } catch (error) { message.error(apiErrorMessage(error)) } finally { setBusyId(undefined) } }
|
||||
function confirmCancel(item: Reservation) { modal.confirm({ title: '取消教室预约', content: item.status === 'Approved' ? '该预约已经批准,取消后教室将立即释放。确认取消吗?' : '确认撤回这条待审核申请吗?', okText: '确认取消', okButtonProps: { danger: true }, onOk: () => cancel(item) }) }
|
||||
function approve(item: Reservation) { modal.confirm({ title: '批准教室借用', content: `确认批准 ${item.applicantName} 对 ${item.buildingName} ${item.classroomName} 的借用申请吗?服务端会再次检查占用冲突。`, okText: '批准', onOk: async () => { try { setBusyId(item.id); await http.post(`/classroom-reservations/${item.id}/approve`, { comment: null }); message.success('申请已批准。'); await review.refetch() } catch (error) { message.error(apiErrorMessage(error)); throw error } finally { setBusyId(undefined) } } }) }
|
||||
async function reject() { if (!rejecting || !rejectComment.trim()) return; try { setBusyId(rejecting.id); await http.post(`/classroom-reservations/${rejecting.id}/reject`, { comment: rejectComment.trim() }); message.success('申请已驳回。'); setRejecting(undefined); setRejectComment(''); await review.refetch() } catch (error) { message.error(apiErrorMessage(error)) } finally { setBusyId(undefined) } }
|
||||
|
||||
if (options.isLoading) return <main className="page-container"><Skeleton active paragraph={{ rows: 12 }} /></main>
|
||||
if (options.isError) return <main className="page-container"><Alert type="error" showIcon message="教室预约基础数据无法加载" description={apiErrorMessage(options.error)} /></main>
|
||||
const page = tab === 'review' ? review : mine
|
||||
return <main className="page-container classroom-reservations-page">
|
||||
<PageHeader kicker="TEACHING SPACE" title="教室借用与预约" description="选择正式课表之外的空闲时段提交申请;系统避让课程、考试和已批准预约,并由申请人所在学院审核。" actions={<Button icon={<ReloadOutlined />} onClick={() => void refresh()}>刷新</Button>} aside={options.data?.applicant ? <div className="reservation-scope"><span>审核学院</span><strong>{options.data.applicant.collegeName}</strong><small>{options.data.applicant.displayName}</small></div> : undefined} />
|
||||
{options.data?.applicantProblem && <Alert type="warning" showIcon message={options.data.applicantProblem} />}
|
||||
<section className="reservation-form-card"><header><div><strong>提交借用申请</strong><span>日期、节次、人数或地点变化后,请重新查询可用教室。</span></div><Tag color={availabilityChecked ? 'green' : 'default'}>{availabilityChecked ? `已找到 ${availableRooms.length} 间可用教室` : '尚未查询可用教室'}</Tag></header><Form form={form} layout="vertical" disabled={!options.data?.applicant} onValuesChange={onValuesChange} onFinish={(values) => void submit(values)}><div className="reservation-grid"><Form.Item name="academicTermId" label="学期" rules={[{ required: true, message: '请选择学期' }]}><Select options={(options.data?.terms ?? []).map((item) => ({ value: item.id, label: `${item.name}${item.hasPublishedTimetable ? '' : '(课表未发布)'}`, disabled: !item.hasPublishedTimetable }))} onChange={(value) => setOptionTermId(value)} /></Form.Item><Form.Item name="reservationDate" label="预约日期" rules={[{ required: true, message: '请选择预约日期' }]}><Input type="date" min={selectedTerm ? (today > selectedTerm.startDate ? today : selectedTerm.startDate) : today} max={selectedTerm?.endDate} /></Form.Item><Form.Item name="startPeriod" label="开始节次" rules={[{ required: true }]}><Select options={(options.data?.timeSlots ?? []).map((item) => ({ value: item.periodNumber, label: `${item.name}${item.startTime ? `(${item.startTime}—${item.endTime})` : ''}` }))} /></Form.Item><Form.Item name="periodCount" label="连续节数" rules={[{ required: true }]}><Select options={periodCounts.map((value) => ({ value, label: `${value} 节` }))} /></Form.Item><Form.Item name="attendeeCount" label="预计人数" rules={[{ required: true, message: '请输入预计人数' }]}><InputNumber min={1} max={10000} /></Form.Item><Form.Item name="campusId" label="校区"><Select allowClear placeholder="全部校区" options={(options.data?.campuses ?? []).map((item) => ({ value: item.id, label: item.name }))} /></Form.Item><Form.Item name="buildingId" label="教学楼"><Select allowClear placeholder="全部教学楼" options={buildings.map((item) => ({ value: item.id, label: item.name }))} /></Form.Item><Form.Item name="classroomId" label="可用教室" rules={[{ required: true, message: '请选择可用教室' }]}><div className="room-search"><Select disabled={!availabilityChecked} showSearch optionFilterProp="label" placeholder={availabilityChecked ? '选择可用教室' : '请先查询可用教室'} options={availableRooms.map((item) => ({ value: item.id, label: `${item.campusName} · ${item.buildingName} · ${item.name}(${item.capacity} 人)` }))} /><Button icon={<SearchOutlined />} loading={availabilityLoading} onClick={() => void searchRooms()}>查询</Button></div></Form.Item><Form.Item name="purpose" label="借用用途" className="reservation-span-2" rules={[{ required: true, whitespace: true, message: '请填写借用用途' }]}><Input maxLength={200} showCount placeholder="例如:学院学术讲座、班级主题活动" /></Form.Item><Form.Item name="contactPhone" label="联系电话" rules={[{ required: true, whitespace: true, message: '请填写联系电话' }]}><Input maxLength={30} placeholder="便于审核人联系" /></Form.Item><Form.Item name="notes" label="补充说明" className="reservation-span-2"><Input.TextArea rows={3} maxLength={500} showCount placeholder="可填写设备、布置或其他需求" /></Form.Item></div><footer><span>{availabilityChecked ? '可提交申请,服务端会在写入时再次检查冲突。' : '查询可用教室后才能提交申请。'}</span><Button type="primary" size="large" loading={busyId === 'create'} htmlType="submit">提交学院审核</Button></footer></Form></section>
|
||||
<section className="reservation-records"><Tabs activeKey={tab} onChange={(value) => setTab(value as 'mine' | 'review')} items={[{ key: 'mine', label: '我的申请' }, ...(isCollegeAdmin ? [{ key: 'review', label: '本院审核' }] : [])]} />{tab === 'review' && <div className="review-toolbar"><div><strong>{options.data?.applicant?.collegeName || user?.displayName}审核队列</strong><span>只显示申请人归属本学院的教室借用申请。</span></div><Select value={reviewStatus} options={[{ value: 'Submitted', label: '待审核' }, { value: 'Approved', label: '已批准' }, { value: 'Rejected', label: '已驳回' }, { value: 'Cancelled', label: '已取消' }, { value: undefined, label: '全部状态' }]} onChange={(value) => { setReviewStatus(value); setReviewPage(1) }} /></div>}<ReservationTable items={page.data?.items ?? []} loading={page.isLoading || page.isFetching} reviewing={tab === 'review'} busyId={busyId} onCancel={confirmCancel} onApprove={approve} onReject={(item) => { setRejecting(item); setRejectComment('') }} />{(page.data?.total ?? 0) > 12 && <Pagination current={tab === 'review' ? reviewPage : minePage} pageSize={12} total={page.data?.total} showSizeChanger={false} onChange={(value) => tab === 'review' ? setReviewPage(value) : setMinePage(value)} />}</section>
|
||||
<Modal open={!!rejecting} title="驳回教室借用" okText="确认驳回" okButtonProps={{ danger: true }} confirmLoading={busyId === rejecting?.id} onCancel={() => setRejecting(undefined)} onOk={() => void reject()}><Alert type="warning" showIcon message="必须填写驳回原因,申请人将在消息中心收到审核意见。" /><label className="reservation-reject">驳回原因<Input.TextArea rows={4} maxLength={500} showCount value={rejectComment} onChange={(event) => setRejectComment(event.target.value)} placeholder="请输入具体原因" /></label></Modal>
|
||||
</main>
|
||||
}
|
||||
Loaded 3 of 6 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user