feat: refine admin forms, attendance and finance workflows

Squash merge PR #23.

Included changes:
- complete occupancy check-in required fields/default payload
- improve responsive admin management pages
- fix attendance edge cases and attendance period config
- refine wallet/finance-related workflow handling

Checks:
- npm run typecheck -w apps/admin
- npm run typecheck -w apps/server
This commit is contained in:
2026-07-18 12:54:10 +00:00
parent 92d303ed01
commit 375c7ec60b
64 changed files with 5169 additions and 2404 deletions

View File

@@ -1,8 +1,23 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Card, Tabs, Descriptions, Table, Button, Space, Select, Modal, Tag,
Popconfirm, Form, Input, DatePicker, InputNumber, Row, Col, Statistic,
Card,
Tabs,
Descriptions,
Table,
Button,
Space,
Select,
Modal,
Tag,
Popconfirm,
Form,
Input,
DatePicker,
InputNumber,
Row,
Col,
Statistic,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { ArrowLeftOutlined, PlusOutlined, DownloadOutlined } from '@ant-design/icons';
@@ -111,7 +126,13 @@ const ROLE_MAP: Record<string, string> = {
};
const WEEK_DAY_MAP: Record<number, string> = {
1: '周一', 2: '周二', 3: '周三', 4: '周四', 5: '周五', 6: '周六', 7: '周日',
1: '周一',
2: '周二',
3: '周三',
4: '周四',
5: '周五',
6: '周六',
7: '周日',
};
const SCHEDULE_TYPE_MAP: Record<string, string> = {
@@ -145,14 +166,18 @@ const ClassDetailPage: React.FC = () => {
// Schedule & attendance state
const [schedules, setSchedules] = useState<ClassScheduleItem[]>([]);
const [scheduleDateRange, setScheduleDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null]>([null, null]);
const [scheduleDateRange, setScheduleDateRange] = useState<
[dayjs.Dayjs | null, dayjs.Dayjs | null]
>([null, null]);
const [attendanceSummary, setAttendanceSummary] = useState<AttendanceSummary | null>(null);
const [attendanceDateRange, setAttendanceDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null]>([null, null]);
const [attendanceDateRange, setAttendanceDateRange] = useState<
[dayjs.Dayjs | null, dayjs.Dayjs | null]
>([null, null]);
const fetchDetail = useCallback(async () => {
setLoading(true);
try {
const res = await api.get(`/classes/${id}`) as ClassDetail;
const res = (await api.get(`/classes/${id}`)) as ClassDetail;
setDetail(res);
setStudents(res.students || []);
setTeachers(res.teachers || []);
@@ -164,7 +189,9 @@ const ClassDetailPage: React.FC = () => {
}
}, [id]);
useEffect(() => { fetchDetail(); }, [fetchDetail]);
useEffect(() => {
fetchDetail();
}, [fetchDetail]);
const fetchSchedules = useCallback(async () => {
if (!id) return;
@@ -180,7 +207,9 @@ const ClassDetailPage: React.FC = () => {
}
}, [id, scheduleDateRange]);
useEffect(() => { fetchSchedules(); }, [fetchSchedules]);
useEffect(() => {
fetchSchedules();
}, [fetchSchedules]);
const fetchAttendanceSummary = useCallback(async () => {
if (!id) return;
@@ -196,7 +225,9 @@ const ClassDetailPage: React.FC = () => {
}
}, [id, attendanceDateRange]);
useEffect(() => { fetchAttendanceSummary(); }, [fetchAttendanceSummary]);
useEffect(() => {
fetchAttendanceSummary();
}, [fetchAttendanceSummary]);
const handleSaveInfo = async () => {
try {
@@ -275,7 +306,9 @@ const ClassDetailPage: React.FC = () => {
const openStudentModal = async () => {
try {
const res = await api.get('/students', { params: { includeArchived: 'false' } }) as StudentItem[];
const res = (await api.get('/students', {
params: { includeArchived: 'false' },
})) as StudentItem[];
setAllStudents(res || []);
setSelectedStudentIds([]);
setStudentModalOpen(true);
@@ -287,7 +320,7 @@ const ClassDetailPage: React.FC = () => {
const openTeacherModal = async () => {
try {
const res = await api.get('/rbac/users') as UserItem[];
const res = (await api.get('/rbac/users')) as UserItem[];
setAllUsers(res || []);
setTeacherUserId(undefined);
setTeacherRole('subject_teacher');
@@ -310,9 +343,7 @@ const ClassDetailPage: React.FC = () => {
title: '状态',
dataIndex: 'status',
render: (v: string) => (
<Tag color={v === 'active' ? 'green' : 'default'}>
{v === 'active' ? '在读' : '已离班'}
</Tag>
<Tag color={v === 'active' ? 'green' : 'default'}>{v === 'active' ? '在读' : '已离班'}</Tag>
),
},
{
@@ -320,7 +351,9 @@ const ClassDetailPage: React.FC = () => {
render: (_: unknown, r: ClassStudent) =>
r.status === 'active' ? (
<Popconfirm title="确认移除?" onConfirm={() => handleRemoveStudent(r.studentId)}>
<PermissionButton permission="class:edit" size="small" danger></PermissionButton>
<PermissionButton permission="class:edit" size="small" danger>
</PermissionButton>
</Popconfirm>
) : null,
},
@@ -342,7 +375,9 @@ const ClassDetailPage: React.FC = () => {
title: '操作',
render: (_: unknown, r: ClassTeacher) => (
<Popconfirm title="确认移除?" onConfirm={() => handleRemoveTeacher(r.userId)}>
<PermissionButton permission="class:edit" size="small" danger></PermissionButton>
<PermissionButton permission="class:edit" size="small" danger>
</PermissionButton>
</Popconfirm>
),
},
@@ -351,15 +386,27 @@ const ClassDetailPage: React.FC = () => {
const scheduleColumns: ColumnsType<ClassScheduleItem> = [
{ title: '教室', dataIndex: 'classroomName', render: (v: string | null) => v || '-' },
{ title: '星期', dataIndex: 'weekDay', render: (v: number) => WEEK_DAY_MAP[v] || v },
{ title: '时间', render: (_: unknown, r: ClassScheduleItem) => `${r.startTime} - ${r.endTime}` },
{ title: '签到窗口', render: (_: unknown, r: ClassScheduleItem) => `课前 ${r.attendanceAdvanceMinutes ?? 30} 分钟至下课` },
{ title: '日期范围', render: (_: unknown, r: ClassScheduleItem) => `${r.startDate} ~ ${r.endDate}` },
{
title: '时间',
render: (_: unknown, r: ClassScheduleItem) => `${r.startTime} - ${r.endTime}`,
},
{
title: '签到窗口',
render: (_: unknown, r: ClassScheduleItem) =>
`课前 ${r.attendanceAdvanceMinutes ?? 30} 分钟至下课`,
},
{
title: '日期范围',
render: (_: unknown, r: ClassScheduleItem) => `${r.startDate} ~ ${r.endDate}`,
},
{ title: '科目', dataIndex: 'subject' },
{ title: '类型', dataIndex: 'scheduleType', render: (v: string) => SCHEDULE_TYPE_MAP[v] || v },
{
title: '状态',
dataIndex: 'status',
render: (v: string) => <Tag color={v === 'active' ? 'green' : 'default'}>{v === 'active' ? '启用' : v}</Tag>,
render: (v: string) => (
<Tag color={v === 'active' ? 'green' : 'default'}>{v === 'active' ? '启用' : v}</Tag>
),
},
];
@@ -368,7 +415,9 @@ const ClassDetailPage: React.FC = () => {
title={
<Space>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/classes')} />
<span>{detail.name} ({detail.code})</span>
<span>
{detail.name} ({detail.code})
</span>
<Tag color={STATUS_MAP[detail.status]?.color}>{STATUS_MAP[detail.status]?.text}</Tag>
</Space>
}
@@ -434,7 +483,11 @@ const ClassDetailPage: React.FC = () => {
<Input.TextArea rows={3} />
</Form.Item>
<Space>
<PermissionButton permission="class:edit" type="primary" onClick={handleSaveInfo}>
<PermissionButton
permission="class:edit"
type="primary"
onClick={handleSaveInfo}
>
</PermissionButton>
<Button onClick={() => setEditingInfo(false)}></Button>
@@ -458,9 +511,7 @@ const ClassDetailPage: React.FC = () => {
<Descriptions.Item label="班主任">
{teachers.find((t) => t.roleType === 'head_teacher')?.username || '-'}
</Descriptions.Item>
<Descriptions.Item label="备注">
{detail.notes || '-'}
</Descriptions.Item>
<Descriptions.Item label="备注">{detail.notes || '-'}</Descriptions.Item>
</Descriptions>
<PermissionButton
permission="class:edit"
@@ -627,10 +678,12 @@ const ClassDetailPage: React.FC = () => {
label: '课表',
children: (
<div>
<Space style={{ marginBottom: 16 }}>
<Space style={{ marginBottom: 16, maxWidth: '100%' }} wrap>
<DatePicker.RangePicker
value={scheduleDateRange}
onChange={(dates) => setScheduleDateRange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null])}
onChange={(dates) =>
setScheduleDateRange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null])
}
placeholder={['开始日期', '结束日期']}
/>
</Space>
@@ -638,6 +691,7 @@ const ClassDetailPage: React.FC = () => {
columns={scheduleColumns}
dataSource={schedules}
rowKey="id"
scroll={{ x: 'max-content' }}
pagination={{
defaultPageSize: 20,
showSizeChanger: true,
@@ -652,31 +706,37 @@ const ClassDetailPage: React.FC = () => {
label: '出勤汇总',
children: (
<div>
<Space style={{ marginBottom: 16 }}>
<Space style={{ marginBottom: 16, maxWidth: '100%' }} wrap>
<DatePicker.RangePicker
value={attendanceDateRange}
onChange={(dates) => setAttendanceDateRange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null])}
onChange={(dates) =>
setAttendanceDateRange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null])
}
placeholder={['开始日期', '结束日期']}
/>
</Space>
{attendanceSummary && (
<Row gutter={16}>
<Col span={6}>
<Row gutter={[16, 16]}>
<Col xs={12} md={6}>
<Card bordered={false}>
<Statistic title="总记录" value={attendanceSummary.total} />
</Card>
</Col>
<Col span={6}>
<Col xs={12} md={6}>
<Card bordered={false}>
<Statistic title="出勤率" value={attendanceSummary.presentRate} suffix="%" />
<Statistic
title="出勤率"
value={attendanceSummary.presentRate}
suffix="%"
/>
</Card>
</Col>
<Col span={6}>
<Col xs={12} md={6}>
<Card bordered={false}>
<Statistic title="缺勤率" value={attendanceSummary.absentRate} suffix="%" />
</Card>
</Col>
<Col span={6}>
<Col xs={12} md={6}>
<Card bordered={false}>
<Statistic title="迟到率" value={attendanceSummary.lateRate} suffix="%" />
</Card>