diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index b8f6297..4163de4 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -24,7 +24,7 @@ const TeachersPage = lazy(() => import('./pages/Teachers')); const StudentProfilePage = lazy(() => import('./pages/StudentProfile')); const ClassesPage = lazy(() => import('./pages/Classes')); const ClassDetailPage = lazy(() => import('./pages/Classes/detail')); -const OrganizationsPage = lazy(() => import('./pages/Organizations')) +const OrganizationsPage = lazy(() => import('./pages/Organizations')); const ClassroomRentalsPage = lazy(() => import('./pages/ClassroomRentals')); const ClassroomSchedulePage = lazy(() => import('./pages/ClassroomSchedule')); const SchedulesPage = lazy(() => import('./pages/Schedules')); @@ -59,254 +59,258 @@ const App: React.FC = () => { - }> - - } /> - - - - } - > - } /> + + + + } + > + + } /> - - + + + } - /> - - - - } - /> - - - - } - /> + > + } /> + + + + } + /> + + + + } + /> + + + + } + /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> - - - - } - /> + + + + } + /> - - - - } - /> + + + + } + /> - - - - } - /> + + + + } + /> - - - - } - /> + + + + } + /> - - - - } - /> + + + + } + /> - - - - - } - /> - - - - - } - /> - - + + + + } + /> + + diff --git a/apps/admin/src/api/index.ts b/apps/admin/src/api/index.ts index 978f547..20af776 100644 --- a/apps/admin/src/api/index.ts +++ b/apps/admin/src/api/index.ts @@ -16,8 +16,7 @@ instance.interceptors.request.use((config) => { instance.interceptors.response.use( (res) => res.data, (err) => { - const isLoginRequest = - err.config?.url === '/auth/login' || err.config?.url === 'auth/login'; + const isLoginRequest = err.config?.url === '/auth/login' || err.config?.url === 'auth/login'; if (err.response?.status === 401 && !isLoginRequest) { localStorage.removeItem('token'); diff --git a/apps/admin/src/auth/menu-policy.integration.test.ts b/apps/admin/src/auth/menu-policy.integration.test.ts index a31724d..ce8f9bc 100644 --- a/apps/admin/src/auth/menu-policy.integration.test.ts +++ b/apps/admin/src/auth/menu-policy.integration.test.ts @@ -45,9 +45,7 @@ describe('role-aware menu policy', () => { '/attendance', '/notifications', ]); - expect(findRoleAwareLandingPath(['任课老师'], teacherPermissions)).toBe( - '/teacher-workspace', - ); + expect(findRoleAwareLandingPath(['任课老师'], teacherPermissions)).toBe('/teacher-workspace'); }); it('places schedules and attendance only once in academic management', () => { diff --git a/apps/admin/src/auth/menu-policy.ts b/apps/admin/src/auth/menu-policy.ts index ad49d2e..aa990b7 100644 --- a/apps/admin/src/auth/menu-policy.ts +++ b/apps/admin/src/auth/menu-policy.ts @@ -43,7 +43,12 @@ const SECTIONS: MenuSection[] = [ icon: 'calendar', roles: ['teacher'], children: [ - { key: '/teacher-workspace', label: '今日教学', icon: 'workspace', permission: 'teacher-workspace:view' }, + { + key: '/teacher-workspace', + label: '今日教学', + icon: 'workspace', + permission: 'teacher-workspace:view', + }, { key: '/schedules', label: '我的排课', icon: 'calendar', permission: 'schedule:view' }, { key: '/attendance', label: '课程考勤', icon: 'attendance', permission: 'attendance:view' }, ], @@ -83,11 +88,26 @@ const SECTIONS: MenuSection[] = [ icon: 'classroom', roles: ['classroom', 'super'], children: [ - { key: '/classroom-schedule', label: '教室排期', icon: 'calendar', permission: 'rental:view' }, + { + key: '/classroom-schedule', + label: '教室排期', + icon: 'calendar', + permission: 'rental:view', + }, { key: '/classrooms', label: '教室管理', icon: 'classroom', permission: 'classroom:view' }, - { key: '/attendance-devices', label: '考勤机绑定', icon: 'attendance', permission: 'classroom:view' }, + { + key: '/attendance-devices', + label: '考勤机绑定', + icon: 'attendance', + permission: 'classroom:view', + }, { key: '/classroom-rentals', label: '租赁订单', icon: 'rental', permission: 'rental:view' }, - { key: '/organizations', label: '机构管理', icon: 'organization', permission: 'organization:view' }, + { + key: '/organizations', + label: '机构管理', + icon: 'organization', + permission: 'organization:view', + }, ], }, { @@ -100,13 +120,21 @@ const SECTIONS: MenuSection[] = [ { key: '/roles', label: '角色管理', icon: 'role', permission: 'role:view' }, { key: '/permissions', label: '权限一览', icon: 'permission', permission: 'role:view' }, { key: '/operation-logs', label: '操作日志', icon: 'log', permission: 'log:view' }, - { key: '/integration-config', label: '钉钉集成', icon: 'integration', permission: 'integration:read' }, + { + key: '/integration-config', + label: '钉钉集成', + icon: 'integration', + permission: 'integration:read', + }, { key: '/ai-config', label: 'AI 配置', icon: 'ai', permission: 'ai:config:read' }, ], }, ]; -export function getRoleDomains(roles: readonly string[], permissions: readonly string[]): Set { +export function getRoleDomains( + roles: readonly string[], + permissions: readonly string[], +): Set { const normalized = new Set(roles.map((role) => ROLE_ALIASES[role]).filter(Boolean)); // 权限可以来自多个叠加角色,因此业务域按能力累加,而不是只选择一个。 if (permissions.includes('student:view') || permissions.includes('class:view')) { @@ -167,7 +195,8 @@ export function buildMenu(roles: readonly string[], permissions: readonly string const children = section.children .filter((child) => permissionSet.has(child.permission)) .map(({ permission: _, ...child }) => child); - if (children.length > 0) sections.push({ ...section, children, roles: undefined } as AppMenuItem); + if (children.length > 0) + sections.push({ ...section, children, roles: undefined } as AppMenuItem); } if (permissionSet.has('notification:view')) { diff --git a/apps/admin/src/auth/permission-navigation.integration.test.ts b/apps/admin/src/auth/permission-navigation.integration.test.ts index facc6a1..16882b8 100644 --- a/apps/admin/src/auth/permission-navigation.integration.test.ts +++ b/apps/admin/src/auth/permission-navigation.integration.test.ts @@ -13,11 +13,7 @@ describe('permission navigation', () => { it('lands teachers on the teacher workspace without global student or class access', () => { expect( - findFirstAccessiblePath([ - 'teacher-workspace:view', - 'schedule:view', - 'attendance:view', - ]), + findFirstAccessiblePath(['teacher-workspace:view', 'schedule:view', 'attendance:view']), ).toBe('/teacher-workspace'); expect(canAccessPath('/students', ['teacher-workspace:view'])).toBe(false); expect(canAccessPath('/classes', ['teacher-workspace:view'])).toBe(false); diff --git a/apps/admin/src/auth/permission-navigation.ts b/apps/admin/src/auth/permission-navigation.ts index 6fde26f..6b09f63 100644 --- a/apps/admin/src/auth/permission-navigation.ts +++ b/apps/admin/src/auth/permission-navigation.ts @@ -14,8 +14,16 @@ export const PERMISSION_PAGES: readonly PermissionPage[] = [ { path: '/rooms', permission: 'room:view' }, { path: '/occupancies', permission: 'occupancy:view' }, { path: '/teacher-workspace', permission: 'teacher-workspace:view' }, - { path: '/students', permission: 'student:view', matches: (p) => p === '/students' || /^\/students\/\d+\/profile$/.test(p) }, - { path: '/classes', permission: 'class:view', matches: (p) => p === '/classes' || /^\/classes\/\d+$/.test(p) }, + { + path: '/students', + permission: 'student:view', + matches: (p) => p === '/students' || /^\/students\/\d+\/profile$/.test(p), + }, + { + path: '/classes', + permission: 'class:view', + matches: (p) => p === '/classes' || /^\/classes\/\d+$/.test(p), + }, { path: '/attendance', permission: 'attendance:view' }, { path: '/schedules', permission: 'schedule:view' }, { path: '/classroom-schedule', permission: 'rental:view' }, diff --git a/apps/admin/src/auth/permission-store.ts b/apps/admin/src/auth/permission-store.ts index e469a08..ecc0d8a 100644 --- a/apps/admin/src/auth/permission-store.ts +++ b/apps/admin/src/auth/permission-store.ts @@ -3,7 +3,9 @@ export const PERMISSIONS_UPDATED_EVENT = 'permissions-updated'; export function readPermissions(): string[] { try { const value = JSON.parse(localStorage.getItem('permissions') || '[]'); - return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : []; } catch { return []; } diff --git a/apps/admin/src/components/DefaultRoute.tsx b/apps/admin/src/components/DefaultRoute.tsx index 201f91c..9366729 100644 --- a/apps/admin/src/components/DefaultRoute.tsx +++ b/apps/admin/src/components/DefaultRoute.tsx @@ -15,7 +15,9 @@ const DefaultRoute: React.FC = () => { })(); const firstPath = findRoleAwareLandingPath(roles, permissions); if (firstPath) return ; - return ; + return ( + + ); }; export default DefaultRoute; diff --git a/apps/admin/src/components/NotificationBell.tsx b/apps/admin/src/components/NotificationBell.tsx index 91e7fb1..bc69f82 100644 --- a/apps/admin/src/components/NotificationBell.tsx +++ b/apps/admin/src/components/NotificationBell.tsx @@ -34,16 +34,20 @@ const NotificationBell: React.FC = () => { const fetchNotifications = async () => { try { - const data = await api.get('/notifications?limit=20') as unknown as NotificationItem[]; + const data = (await api.get('/notifications?limit=20')) as unknown as NotificationItem[]; setNotifications(data); - } catch { /* ignore */ } + } catch { + /* ignore */ + } }; const fetchUnread = async () => { try { - const data = await api.get('/notifications/unread-count') as unknown as { count: number }; + const data = (await api.get('/notifications/unread-count')) as unknown as { count: number }; setUnreadCount(data.count); - } catch { /* ignore */ } + } catch { + /* ignore */ + } }; const openRef = useRef(open); openRef.current = open; @@ -60,7 +64,9 @@ const NotificationBell: React.FC = () => { JSON.parse(event.data); setUnreadCount((c) => c + 1); if (openRef.current) fetchNotifications(); - } catch { /* ignore */ } + } catch { + /* ignore */ + } }; es.onerror = () => { es.close(); @@ -84,7 +90,9 @@ const NotificationBell: React.FC = () => { try { await api.put(`/notifications/${item.id}/read`); setUnreadCount((c) => Math.max(0, c - 1)); - } catch { /* ignore */ } + } catch { + /* ignore */ + } } setOpen(false); if (item.link) navigate(item.link); @@ -94,10 +102,10 @@ const NotificationBell: React.FC = () => { try { await api.put('/notifications/read-all'); setUnreadCount(0); - setNotifications((prev) => - prev.map((n) => ({ ...n, isRead: true })), - ); - } catch { /* ignore */ } + setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true }))); + } catch { + /* ignore */ + } }; const content = ( @@ -148,18 +156,13 @@ const NotificationBell: React.FC = () => { ) } title={ - - [{notificationTypeLabels[item.type] || item.type}] {formatNotificationText(item.title)} + + [{notificationTypeLabels[item.type] || item.type}]{' '} + {formatNotificationText(item.title)} } description={ - + {timeAgo(item.createdAt)} } diff --git a/apps/admin/src/components/PermissionRoute.tsx b/apps/admin/src/components/PermissionRoute.tsx index 9a3b880..fc557eb 100644 --- a/apps/admin/src/components/PermissionRoute.tsx +++ b/apps/admin/src/components/PermissionRoute.tsx @@ -25,7 +25,13 @@ const PermissionRoute: React.FC = ({ permission, children status="403" title="无权访问" subTitle="您没有访问此页面的权限" - extra={firstPath ? : undefined} + extra={ + firstPath ? ( + + ) : undefined + } /> ); } diff --git a/apps/admin/src/components/StudentProfileContent/index.tsx b/apps/admin/src/components/StudentProfileContent/index.tsx index 3203a81..3877f22 100644 --- a/apps/admin/src/components/StudentProfileContent/index.tsx +++ b/apps/admin/src/components/StudentProfileContent/index.tsx @@ -215,7 +215,9 @@ const getStudentStatus = (value?: string | null): { text: string; color: string const formatEnrollmentDisplayName = (enrollment: EnrollmentRecord): string => enrollment.className || - (enrollment.courseCategory ? getCourseCategoryLabel(enrollment.courseCategory) : String(enrollment.id)); + (enrollment.courseCategory + ? getCourseCategoryLabel(enrollment.courseCategory) + : String(enrollment.id)); const ATTACHMENT_CATEGORY_OPTIONS = [ { value: 'id_card', label: '身份证' }, @@ -253,16 +255,31 @@ const SESSION_LABELS: Record = { const AttendanceTab: React.FC<{ data: AttendanceRecordItem[] }> = ({ data }) => { const columns: ColumnsType = [ { title: '日期', dataIndex: 'attendanceDate', width: 120 }, - { title: '课程', render: (_: unknown, record) => record.schedule?.subject || record.class?.name || '课程考勤' }, - { title: '时段', dataIndex: 'session', width: 100, render: (value: string) => SESSION_LABELS[value] || value || '-' }, { - title: '结果', dataIndex: 'status', width: 90, + title: '课程', + render: (_: unknown, record) => record.schedule?.subject || record.class?.name || '课程考勤', + }, + { + title: '时段', + dataIndex: 'session', + width: 100, + render: (value: string) => SESSION_LABELS[value] || value || '-', + }, + { + title: '结果', + dataIndex: 'status', + width: 90, render: (value: string) => { const meta = ATTENDANCE_STATUS_MAP[value] || { text: value || '-', color: 'default' }; return {meta.text}; }, }, - { title: '打卡时间', dataIndex: 'punchTime', width: 170, render: (value?: string | null) => value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '-' }, + { + title: '打卡时间', + dataIndex: 'punchTime', + width: 170, + render: (value?: string | null) => (value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '-'), + }, { title: '打卡设备', render: (_: unknown, record) => { @@ -283,7 +300,9 @@ const AttendanceTab: React.FC<{ data: AttendanceRecordItem[] }> = ({ data }) => scroll={{ x: 900 }} pagination={{ defaultPageSize: 15, showSizeChanger: true, pageSizeOptions: [15, 30, 50] }} /> - ) : ; + ) : ( + + ); }; interface TabProps { @@ -291,11 +310,11 @@ interface TabProps { onRefresh: () => void; } -const ProfileTab: React.FC<{ data: ProfileData | null; studentId: number; onRefresh: () => void }> = ({ - data, - studentId, - onRefresh, -}) => { +const ProfileTab: React.FC<{ + data: ProfileData | null; + studentId: number; + onRefresh: () => void; +}> = ({ data, studentId, onRefresh }) => { const [form] = Form.useForm(); const [saving, setSaving] = useState(false); @@ -439,10 +458,18 @@ const EnrollmentsTab: React.FC = ({ confirmLoading={saving} >
- + @@ -466,12 +493,9 @@ const EnrollmentsTab: React.FC = ({ ); }; -const ExamScoresTab: React.FC = ({ - data, - studentId, - enrollments, - onRefresh, -}) => { +const ExamScoresTab: React.FC< + TabProps & { data: ExamScoreRecord[]; enrollments: EnrollmentRecord[] } +> = ({ data, studentId, enrollments, onRefresh }) => { const [modalOpen, setModalOpen] = useState(false); const [form] = Form.useForm(); const [saving, setSaving] = useState(false); @@ -505,8 +529,16 @@ const ExamScoresTab: React.FC v || '-' }, { title: '科目', dataIndex: 'subject' }, { title: '成绩', dataIndex: 'score' }, - { title: '班级均分', dataIndex: 'classAvg', render: (v: number | undefined) => (v !== undefined ? v : '-') }, - { title: '排名', dataIndex: 'rank', render: (v: number | undefined) => (v !== undefined ? v : '-') }, + { + title: '班级均分', + dataIndex: 'classAvg', + render: (v: number | undefined) => (v !== undefined ? v : '-'), + }, + { + title: '排名', + dataIndex: 'rank', + render: (v: number | undefined) => (v !== undefined ? v : '-'), + }, { title: '考试日期', dataIndex: 'examDate', render: (v: string) => v || '-' }, { title: '关联报读', @@ -550,13 +582,21 @@ const ExamScoresTab: React.FC - + - + @@ -587,7 +627,11 @@ const ExamScoresTab: React.FC = ({ data, studentId, onRefresh }) => { +const LearningTab: React.FC = ({ + data, + studentId, + onRefresh, +}) => { const [modalOpen, setModalOpen] = useState(false); const [form] = Form.useForm(); const [saving, setSaving] = useState(false); @@ -655,13 +699,25 @@ const LearningTab: React.FC = ({ data, st confirmLoading={saving} > - + - + + - - - - + + + {(fields, { add, remove }) => ( +
+ {fields.map((field) => ( +
+ + + + + + + + + + + + + + - + - - - diff --git a/apps/admin/src/pages/Bills/index.tsx b/apps/admin/src/pages/Bills/index.tsx index af2d466..3722c62 100644 --- a/apps/admin/src/pages/Bills/index.tsx +++ b/apps/admin/src/pages/Bills/index.tsx @@ -27,7 +27,6 @@ import { message } from '../../ui/app-message'; import { buildBillPrintHtml, type BillPrintData } from './bill-print'; import { newOperationId } from '../../utils/operation-id'; - const statusMap: Record = { unpaid: { text: '待支付', color: 'orange' }, partially_paid: { text: '部分支付', color: 'gold' }, @@ -64,7 +63,7 @@ const BillsPage: React.FC = () => { const params: Record = {}; if (filterStatus) params.status = filterStatus; if (filterExpenseType) params.expenseType = filterExpenseType; - const res = await api.get('/bills', { params }) as unknown[]; + const res = (await api.get('/bills', { params })) as unknown[]; setBills(res); } catch (e: any) { message.error(e?.message || '加载失败,请稍后重试'); @@ -120,17 +119,30 @@ const BillsPage: React.FC = () => { } }; - - const handleCancel = async (id: number) => { let reason = ''; Modal.confirm({ title: '取消账单并退回已扣余额', - content: { reason = event.target.value; }} />, - okText: '确认取消', cancelText: '返回', + content: ( + { + reason = event.target.value; + }} + /> + ), + okText: '确认取消', + cancelText: '返回', onOk: async () => { - if (!reason.trim()) { message.error('请输入取消原因'); throw new Error('reason required'); } - await api.post(`/bills/${id}/cancel`, { operationId: newOperationId(), reason: reason.trim() }); + if (!reason.trim()) { + message.error('请输入取消原因'); + throw new Error('reason required'); + } + await api.post(`/bills/${id}/cancel`, { + operationId: newOperationId(), + reason: reason.trim(), + }); message.success('账单已取消,已扣余额已冲正退回'); fetchData(); }, @@ -142,7 +154,9 @@ const BillsPage: React.FC = () => { await api.delete(`/bills/${id}`); message.success('账单已归档'); fetchData(); - } catch (error: any) { message.error(error?.message || '归档失败'); } + } catch (error: any) { + message.error(error?.message || '归档失败'); + } }; const batchArchive = async () => { @@ -175,7 +189,9 @@ const BillsPage: React.FC = () => { return; } - printWindow.document.write('

正在加载账单...

'); + printWindow.document.write( + '

正在加载账单...

', + ); try { const bill = await api.get(`/bills/${billId}`); printWindow.document.open(); @@ -187,89 +203,125 @@ const BillsPage: React.FC = () => { } }; - const columns = useMemo(() => [ - { title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' }, - { title: '账单周期', width: 200, render: (_: any, r: any) => `${r.periodStart} ~ ${r.periodEnd}` }, - { - title: '分摊费用', - dataIndex: 'sharedAmount', - width: 120, - align: 'right' as const, - render: (v: number) => `¥${Number(v).toFixed(2)}`, - }, - { - title: '个人费用', - dataIndex: 'personalAmount', - width: 120, - align: 'right' as const, - render: (v: number) => `¥${Number(v).toFixed(2)}`, - }, - { - title: '总计', - dataIndex: 'totalAmount', - width: 100, - align: 'right' as const, - render: (v: number) => ¥{Number(v).toFixed(2)}, - }, - { - title: '已扣余额', dataIndex: 'paidAmount', width: 110, - render: (value: number) => ¥{Number(value || 0).toFixed(2)}, - }, - { - title: '待补缴', dataIndex: 'outstandingAmount', width: 110, - render: (value: number) => 0 ? '#cf1322' : '#389e0d' }}>¥{Number(value || 0).toFixed(2)}, - }, - { - title: '钱包余额', dataIndex: 'walletBalance', width: 110, - render: (value: number) => `¥${Number(value || 0).toFixed(2)}`, - }, - { - title: '状态', - dataIndex: 'status', - width: 90, - render: (s: string) => {statusMap[s]?.text}, - }, - { - title: '生成时间', - dataIndex: 'generatedAt', - width: 160, - render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'), - }, - { - title: '操作', - width: 320, - render: (_: any, record: any) => ( - - showDetail(record.id)} - > - 详情 - - } - onClick={() => handleExportPdf(record.id)} - > - PDF - - {record.status !== 'cancelled' && ( - handleCancel(record.id)}> - 取消并冲正 + const columns = useMemo( + () => [ + { title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' }, + { + title: '账单周期', + width: 200, + render: (_: any, r: any) => `${r.periodStart} ~ ${r.periodEnd}`, + }, + { + title: '分摊费用', + dataIndex: 'sharedAmount', + width: 120, + align: 'right' as const, + render: (v: number) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '个人费用', + dataIndex: 'personalAmount', + width: 120, + align: 'right' as const, + render: (v: number) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '总计', + dataIndex: 'totalAmount', + width: 100, + align: 'right' as const, + render: (v: number) => ¥{Number(v).toFixed(2)}, + }, + { + title: '已扣余额', + dataIndex: 'paidAmount', + width: 110, + render: (value: number) => ( + ¥{Number(value || 0).toFixed(2)} + ), + }, + { + title: '待补缴', + dataIndex: 'outstandingAmount', + width: 110, + render: (value: number) => ( + 0 ? '#cf1322' : '#389e0d' }}> + ¥{Number(value || 0).toFixed(2)} + + ), + }, + { + title: '钱包余额', + dataIndex: 'walletBalance', + width: 110, + render: (value: number) => `¥${Number(value || 0).toFixed(2)}`, + }, + { + title: '状态', + dataIndex: 'status', + width: 90, + render: (s: string) => {statusMap[s]?.text}, + }, + { + title: '生成时间', + dataIndex: 'generatedAt', + width: 160, + render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'), + }, + { + title: '操作', + width: 320, + render: (_: any, record: any) => ( + + showDetail(record.id)} + > + 详情 - )} - {Number(record.paidAmount || 0) === 0 && record.status !== 'cancelled' && ( - handleArchive(record.id)} okText="归档" cancelText="取消"> - }>归档 - - )} - - ), - }, - ], [showDetail, handleArchive, handleCancel, handleExportPdf]); + } + onClick={() => handleExportPdf(record.id)} + > + PDF + + {record.status !== 'cancelled' && ( + handleCancel(record.id)} + > + 取消并冲正 + + )} + {Number(record.paidAmount || 0) === 0 && record.status !== 'cancelled' && ( + handleArchive(record.id)} + okText="归档" + cancelText="取消" + > + } + > + 归档 + + + )} + + ), + }, + ], + [showDetail, handleArchive, handleCancel, handleExportPdf], + ); return (
@@ -297,8 +349,20 @@ const BillsPage: React.FC = () => { { value: 'cancelled', label: '已取消' }, ]} /> - { picker="month" placeholder="选择月份" format="YYYY-MM" - disabledDate={(current) => !!current && !current.endOf('month').isBefore(dayjs(), 'day')} + disabledDate={(current) => + !!current && !current.endOf('month').isBefore(dayjs(), 'day') + } /> @@ -412,9 +478,15 @@ const BillsPage: React.FC = () => { - ¥{Number(detailModal.paidAmount || 0).toFixed(2)} - ¥{Number(detailModal.outstandingAmount || 0).toFixed(2)} - ¥{Number(detailModal.walletBalance || 0).toFixed(2)} + + ¥{Number(detailModal.paidAmount || 0).toFixed(2)} + + + ¥{Number(detailModal.outstandingAmount || 0).toFixed(2)} + + + ¥{Number(detailModal.walletBalance || 0).toFixed(2)} +

费用明细

= { }; const WEEK_DAY_MAP: Record = { - 1: '周一', 2: '周二', 3: '周三', 4: '周四', 5: '周五', 6: '周六', 7: '周日', + 1: '周一', + 2: '周二', + 3: '周三', + 4: '周四', + 5: '周五', + 6: '周六', + 7: '周日', }; const SCHEDULE_TYPE_MAP: Record = { @@ -145,14 +166,18 @@ const ClassDetailPage: React.FC = () => { // Schedule & attendance state const [schedules, setSchedules] = useState([]); - 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(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) => ( - - {v === 'active' ? '在读' : '已离班'} - + {v === 'active' ? '在读' : '已离班'} ), }, { @@ -320,7 +351,9 @@ const ClassDetailPage: React.FC = () => { render: (_: unknown, r: ClassStudent) => r.status === 'active' ? ( handleRemoveStudent(r.studentId)}> - 移除 + + 移除 + ) : null, }, @@ -342,7 +375,9 @@ const ClassDetailPage: React.FC = () => { title: '操作', render: (_: unknown, r: ClassTeacher) => ( handleRemoveTeacher(r.userId)}> - 移除 + + 移除 + ), }, @@ -351,15 +386,27 @@ const ClassDetailPage: React.FC = () => { const scheduleColumns: ColumnsType = [ { 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) => {v === 'active' ? '启用' : v}, + render: (v: string) => ( + {v === 'active' ? '启用' : v} + ), }, ]; @@ -368,7 +415,9 @@ const ClassDetailPage: React.FC = () => { title={ @@ -458,9 +511,7 @@ const ClassDetailPage: React.FC = () => { {teachers.find((t) => t.roleType === 'head_teacher')?.username || '-'} - - {detail.notes || '-'} - + {detail.notes || '-'} { label: '课表', children: (
- + setScheduleDateRange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null])} + onChange={(dates) => + setScheduleDateRange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null]) + } placeholder={['开始日期', '结束日期']} /> @@ -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: (
- + setAttendanceDateRange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null])} + onChange={(dates) => + setAttendanceDateRange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null]) + } placeholder={['开始日期', '结束日期']} /> {attendanceSummary && ( - -
+ + - + - + - + - + diff --git a/apps/admin/src/pages/Classes/index.tsx b/apps/admin/src/pages/Classes/index.tsx index a815c2d..519850f 100644 --- a/apps/admin/src/pages/Classes/index.tsx +++ b/apps/admin/src/pages/Classes/index.tsx @@ -1,7 +1,19 @@ import React, { useEffect, useState, useMemo, useCallback } from 'react'; import { - Table, Button, Input, Select, Space, Tag, Modal, Form, InputNumber, - DatePicker, Popconfirm, Card, Switch, Empty, + Table, + Button, + Input, + Select, + Space, + Tag, + Modal, + Form, + InputNumber, + DatePicker, + Popconfirm, + Card, + Switch, + Empty, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { PlusOutlined, SearchOutlined, TeamOutlined, InboxOutlined } from '@ant-design/icons'; @@ -96,13 +108,14 @@ const ClassesPage: React.FC = () => { setData(res); } catch (e: any) { message.error(e?.message || '加载失败,请稍后重试'); - } - finally { + } finally { setLoading(false); } }, [filterStatus, filterType, showArchived]); - useEffect(() => { fetchData(); }, [fetchData]); + useEffect(() => { + fetchData(); + }, [fetchData]); const filtered = useMemo(() => { if (!searchText) return data; @@ -154,58 +167,86 @@ const ClassesPage: React.FC = () => { } }; - const columns: ColumnsType = useMemo(() => [ - { - title: '班级名称', dataIndex: 'name', width: 120, - sorter: (a, b) => a.name.localeCompare(b.name), - }, - { title: '编码', dataIndex: 'code', width: 140 }, - { - title: '班型', dataIndex: 'classType', width: 100, - render: (v: string) => {TYPE_MAP[v] || v}, - }, - { - title: '开班日期', dataIndex: 'startDate', width: 110, - render: (v: string | null) => v || '-', - }, - { - title: '学员', width: 100, - render: (_: unknown, r: ClassItem) => `${r.studentCount || 0}/${r.maxStudents || '-'}`, - }, - { - title: '状态', dataIndex: 'status', width: 100, - render: (v: string) => { - const cfg = STATUS_MAP[v] || { color: 'default', text: v }; - return {cfg.text}; + const columns: ColumnsType = useMemo( + () => [ + { + title: '班级名称', + dataIndex: 'name', + width: 120, + sorter: (a, b) => a.name.localeCompare(b.name), }, - }, - { - title: '操作', width: 280, - render: (_: unknown, r: ClassItem) => ( - - - handleEdit(r)}> - 编辑 - - {r.isArchived ? ( - handleArchive(r.id, false)}> - 恢复 - - ) : ( - handleArchive(r.id, true)}> - 归档 - - )} - - ), - }, - ], []); + { title: '编码', dataIndex: 'code', width: 140 }, + { + title: '班型', + dataIndex: 'classType', + width: 100, + render: (v: string) => {TYPE_MAP[v] || v}, + }, + { + title: '开班日期', + dataIndex: 'startDate', + width: 110, + render: (v: string | null) => v || '-', + }, + { + title: '学员', + width: 100, + render: (_: unknown, r: ClassItem) => `${r.studentCount || 0}/${r.maxStudents || '-'}`, + }, + { + title: '状态', + dataIndex: 'status', + width: 100, + render: (v: string) => { + const cfg = STATUS_MAP[v] || { color: 'default', text: v }; + return {cfg.text}; + }, + }, + { + title: '操作', + width: 280, + render: (_: unknown, r: ClassItem) => ( + + + handleEdit(r)}> + 编辑 + + {r.isArchived ? ( + handleArchive(r.id, false)}> + + 恢复 + + + ) : ( + handleArchive(r.id, true)} + > + + 归档 + + + )} + + ), + }, + ], + [], + ); return ( - + } @@ -229,7 +270,12 @@ const ClassesPage: React.FC = () => { onChange={setFilterStatus} options={Object.entries(STATUS_MAP).map(([k, v]) => ({ value: k, label: v.text }))} /> - } onClick={handleCreate}> + } + onClick={handleCreate} + > 创建班级 @@ -287,7 +333,9 @@ const ClassesPage: React.FC = () => { - ({ value: k, label: v.text }))} + /> diff --git a/apps/admin/src/pages/ClassroomRentals/index.tsx b/apps/admin/src/pages/ClassroomRentals/index.tsx index 1c06816..f4314fc 100644 --- a/apps/admin/src/pages/ClassroomRentals/index.tsx +++ b/apps/admin/src/pages/ClassroomRentals/index.tsx @@ -15,7 +15,13 @@ import { Tooltip, Empty, } from 'antd'; -import { PlusOutlined, UploadOutlined, FileTextOutlined, StopOutlined, CheckOutlined } from '@ant-design/icons'; +import { + PlusOutlined, + UploadOutlined, + FileTextOutlined, + StopOutlined, + CheckOutlined, +} from '@ant-design/icons'; import dayjs, { Dayjs } from 'dayjs'; import api from '../../api'; import { downloadBlob } from '../../utils/download'; @@ -392,17 +398,36 @@ const ClassroomRentalsPage: React.FC = () => { {record.effectiveStatus === 'active' && ( <> - openEdit(record)}> + openEdit(record)} + > 编辑 - handleRentalAction(record.id, 'cancel')}> - }> + handleRentalAction(record.id, 'cancel')} + > + } + > 取消 {!dayjs(record.startDate).isAfter(dayjs(), 'day') && ( - handleRentalAction(record.id, 'end')}> - }> + handleRentalAction(record.id, 'end')} + > + } + > 结束 @@ -410,8 +435,13 @@ const ClassroomRentalsPage: React.FC = () => { )} {record.effectiveStatus !== 'active' && ( - handleDelete(record.id)}> - 归档 + handleDelete(record.id)} + > + + 归档 + )} @@ -511,10 +541,12 @@ const ClassroomRentalsPage: React.FC = () => { optionFilterProp="label" placeholder="选择教室" onChange={handleClassroomChange} - options={classrooms.filter((c) => c.status === 'available').map((c) => ({ - value: c.id, - label: `${c.building ? c.building + ' · ' : ''}${c.name}(${c.roomType})`, - }))} + options={classrooms + .filter((c) => c.status === 'available') + .map((c) => ({ + value: c.id, + label: `${c.building ? c.building + ' · ' : ''}${c.name}(${c.roomType})`, + }))} /> @@ -559,10 +591,10 @@ const ClassroomRentalsPage: React.FC = () => { /> - + - + diff --git a/apps/admin/src/pages/Classrooms/index.tsx b/apps/admin/src/pages/Classrooms/index.tsx index b528742..60d25a6 100644 --- a/apps/admin/src/pages/Classrooms/index.tsx +++ b/apps/admin/src/pages/Classrooms/index.tsx @@ -60,8 +60,16 @@ const ClassroomsPage: React.FC = () => { const filteredData = useMemo(() => { let result = data; - if (searchText) { const s = searchText.toLowerCase(); result = result.filter((d: Record) => (typeof d.name === 'string' && d.name.toLowerCase().includes(s)) || (typeof d.building === 'string' && d.building.toLowerCase().includes(s))); } - if (filterStatus) result = result.filter((d: Record) => d.effectiveStatus === filterStatus); + if (searchText) { + const s = searchText.toLowerCase(); + result = result.filter( + (d: Record) => + (typeof d.name === 'string' && d.name.toLowerCase().includes(s)) || + (typeof d.building === 'string' && d.building.toLowerCase().includes(s)), + ); + } + if (filterStatus) + result = result.filter((d: Record) => d.effectiveStatus === filterStatus); return result; }, [data, searchText, filterStatus]); @@ -140,72 +148,98 @@ const ClassroomsPage: React.FC = () => { .catch(() => message.error('下载失败')); }; - const columns = useMemo(() => [ - { - title: '教室名', width: 120, - dataIndex: 'name', - sorter: (a: any, b: any) => a.name.localeCompare(b.name), - }, - { title: '楼栋', dataIndex: 'building', width: 80 }, - { title: '楼层', dataIndex: 'floor', width: 80 }, - { - title: '类型', width: 90, - dataIndex: 'roomType', - render: (v: string) => {v || '-'}, - }, - { title: '容量', dataIndex: 'capacity', width: 80 }, - { - title: '状态', width: 100, - dataIndex: 'status', - render: (_s: string, record: { effectiveStatus?: string; status: string; currentUsage?: CurrentUsage | null }) => { - const effectiveStatus = record.effectiveStatus || record.status; - return ( - - {statusMap[effectiveStatus]?.text || effectiveStatus} - - ); + const columns = useMemo( + () => [ + { + title: '教室名', + width: 120, + dataIndex: 'name', + sorter: (a: any, b: any) => a.name.localeCompare(b.name), }, - }, - { - title: '操作', - width: 180, - render: (_: any, record: any) => ( - - {record.status === 'archived' ? ( - handleRestore(record.id)}> - } type="link"> - 恢复 - - - ) : ( - <> - { - setEditing(record); - form.setFieldsValue(record); - setModalOpen(true); - }} - > - 编辑 - - handleArchive(record.id)} - okText="归档" - cancelText="取消" - > - }> - 归档 + { title: '楼栋', dataIndex: 'building', width: 80 }, + { title: '楼层', dataIndex: 'floor', width: 80 }, + { + title: '类型', + width: 90, + dataIndex: 'roomType', + render: (v: string) => {v || '-'}, + }, + { title: '容量', dataIndex: 'capacity', width: 80 }, + { + title: '状态', + width: 100, + dataIndex: 'status', + render: ( + _s: string, + record: { effectiveStatus?: string; status: string; currentUsage?: CurrentUsage | null }, + ) => { + const effectiveStatus = record.effectiveStatus || record.status; + return ( + + + {statusMap[effectiveStatus]?.text || effectiveStatus} + + + ); + }, + }, + { + title: '操作', + width: 180, + render: (_: any, record: any) => ( + + {record.status === 'archived' ? ( + handleRestore(record.id)}> + } + type="link" + > + 恢复 - - )} - - ), - }, - ], []); + ) : ( + <> + { + setEditing(record); + form.setFieldsValue(record); + setModalOpen(true); + }} + > + 编辑 + + handleArchive(record.id)} + okText="归档" + cancelText="取消" + > + } + > + 归档 + + + + )} + + ), + }, + ], + [], + ); return (
@@ -228,7 +262,20 @@ const ClassroomsPage: React.FC = () => { if (!e.target.value) setSearchText(''); }} /> -
{ options={studentOptions} /> - - + + - + @@ -574,7 +628,7 @@ const DepositsPage: React.FC = () => {
当前可用押金: ¥{Number(refundModal?.amount || 0).toFixed(2)}
- + @@ -594,19 +648,34 @@ const DepositsPage: React.FC = () => { {detailModal && (
-

当前可用押金: ¥{Number(detailModal.amount).toFixed(2)}

-

最近收取日期: {detailModal.paidDate}

+

+ 当前可用押金: ¥{Number(detailModal.amount).toFixed(2)} +

+

+ 最近收取日期: {detailModal.paidDate} +

状态:{' '} {statusMap[detailModal.status]?.text || detailModal.status}

- {detailModal.notes &&

备注: {detailModal.notes}

} + {detailModal.notes && ( +

+ 备注: {detailModal.notes} +

+ )}
{/* Installments Section */} -
+

分期记录

{ title="确定归档?" onConfirm={() => handleDeleteInstallment(item.id)} > - }> + } + > 归档 , @@ -676,10 +751,10 @@ const DepositsPage: React.FC = () => { okText="确认" > - - + + - + diff --git a/apps/admin/src/pages/Expenses/index.tsx b/apps/admin/src/pages/Expenses/index.tsx index e5a45e8..d0f923b 100644 --- a/apps/admin/src/pages/Expenses/index.tsx +++ b/apps/admin/src/pages/Expenses/index.tsx @@ -32,11 +32,9 @@ import { message } from '../../ui/app-message'; const { RangePicker } = DatePicker; const isFormValidationError = (error: unknown) => - typeof error === 'object' - && error !== null - && Array.isArray((error as { errorFields?: unknown }).errorFields); - - + typeof error === 'object' && + error !== null && + Array.isArray((error as { errorFields?: unknown }).errorFields); const ExpensesPage: React.FC = () => { const [roomExpenses, setRoomExpenses] = useState([]); @@ -63,27 +61,32 @@ const ExpensesPage: React.FC = () => { // Dynamic expense type options from API const [typeOptions, setTypeOptions] = useState<{ value: string; label: string }[]>([]); - const [personalTypeOptions, setPersonalTypeOptions] = useState<{ value: string; label: string }[]>([]); + const [personalTypeOptions, setPersonalTypeOptions] = useState< + { value: string; label: string }[] + >([]); const [typeMap, setTypeMap] = useState>({}); useEffect(() => { - api.get>('/expense-types').then((types) => { - const roomTypes: { value: string; label: string }[] = []; - const personalTypes: { value: string; label: string }[] = []; - const map: Record = {}; - for (const t of types) { - map[t.code] = t.name; - if (t.category === 'room' || t.category === 'both') { - roomTypes.push({ value: t.code, label: t.name }); + api + .get>('/expense-types') + .then((types) => { + const roomTypes: { value: string; label: string }[] = []; + const personalTypes: { value: string; label: string }[] = []; + const map: Record = {}; + for (const t of types) { + map[t.code] = t.name; + if (t.category === 'room' || t.category === 'both') { + roomTypes.push({ value: t.code, label: t.name }); + } + if (t.category === 'personal' || t.category === 'both') { + personalTypes.push({ value: t.code, label: t.name }); + } } - if (t.category === 'personal' || t.category === 'both') { - personalTypes.push({ value: t.code, label: t.name }); - } - } - setTypeOptions(roomTypes); - setPersonalTypeOptions(personalTypes); - setTypeMap(map); - }).catch(() => {}); + setTypeOptions(roomTypes); + setPersonalTypeOptions(personalTypes); + setTypeMap(map); + }) + .catch(() => {}); }, []); const handleBatchDeleteRoom = async () => { @@ -207,12 +210,17 @@ const ExpensesPage: React.FC = () => { description: values.description, }); const bill = result.bill; - message.success(`账单已生成,已从余额扣除 ¥${Number(bill.paidAmount || 0).toFixed(2)},待补缴 ¥${Number(bill.outstandingAmount || 0).toFixed(2)}`); + message.success( + `账单已生成,已从余额扣除 ¥${Number(bill.paidAmount || 0).toFixed(2)},待补缴 ¥${Number(bill.outstandingAmount || 0).toFixed(2)}`, + ); setUtilityModal(false); utilityForm.resetFields(); fetchData(); - } catch (e: any) { message.error(e?.message || '水电费出账失败'); } - finally { setSaving(false); } + } catch (e: any) { + message.error(e?.message || '水电费出账失败'); + } finally { + setSaving(false); + } }; const handlePersonalExpense = async () => { @@ -247,121 +255,139 @@ const ExpensesPage: React.FC = () => { } }; - const roomColumns = useMemo(() => [ - { title: '宿舍', width: 120, render: (_: any, r: any) => r.room?.roomNumber || '-' }, - { - title: '费用类型', width: 100, - dataIndex: 'expenseType', - render: (v: string) => {typeMap[v] || v}, - }, - { title: '金额', dataIndex: 'amount', width: 100, render: (v: number) => `¥${Number(v).toFixed(2)}` }, - { title: '账单周期', width: 200, render: (_: any, r: any) => `${r.periodStart} ~ ${r.periodEnd}` }, - { title: '说明', dataIndex: 'description', width: 150 }, - { - title: '录入时间', width: 160, - dataIndex: 'createdAt', - render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'), - }, - { - title: '操作', - width: 120, - render: (_: any, record: any) => ( - - } - onClick={() => { - setEditingRoom(record); - roomForm.setFieldsValue({ - roomId: record.roomId, - expenseType: record.expenseType, - amount: Number(record.amount), - period: [dayjs(record.periodStart), dayjs(record.periodEnd)], - description: record.description, - }); - setRoomModal(true); - }} - > - 编辑 - - { - await api.delete(`/expenses/room/${record.id}`); - message.success('归档成功'); - fetchData(); - }} - > + const roomColumns = useMemo( + () => [ + { title: '宿舍', width: 120, render: (_: any, r: any) => r.room?.roomNumber || '-' }, + { + title: '费用类型', + width: 100, + dataIndex: 'expenseType', + render: (v: string) => {typeMap[v] || v}, + }, + { + title: '金额', + dataIndex: 'amount', + width: 100, + render: (v: number) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '账单周期', + width: 200, + render: (_: any, r: any) => `${r.periodStart} ~ ${r.periodEnd}`, + }, + { title: '说明', dataIndex: 'description', width: 150 }, + { + title: '录入时间', + width: 160, + dataIndex: 'createdAt', + render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'), + }, + { + title: '操作', + width: 120, + render: (_: any, record: any) => ( + } + icon={} + onClick={() => { + setEditingRoom(record); + roomForm.setFieldsValue({ + roomId: record.roomId, + expenseType: record.expenseType, + amount: Number(record.amount), + period: [dayjs(record.periodStart), dayjs(record.periodEnd)], + description: record.description, + }); + setRoomModal(true); + }} > - 归档 + 编辑 - - - ), - }, - ], [setEditingRoom, roomForm, setRoomModal, fetchData, typeMap]); + { + await api.delete(`/expenses/room/${record.id}`); + message.success('归档成功'); + fetchData(); + }} + > + } + > + 归档 + + + + ), + }, + ], + [setEditingRoom, roomForm, setRoomModal, fetchData, typeMap], + ); - const personalColumns = useMemo(() => [ - { title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' }, - { - title: '费用类型', width: 100, - dataIndex: 'expenseType', - render: (v: string) => {typeMap[v] || v}, - }, - { title: '金额', dataIndex: 'amount', render: (v: number) => `¥${Number(v).toFixed(2)}` }, - { title: '日期', dataIndex: 'expenseDate', width: 110 }, - { title: '说明', dataIndex: 'description', width: 150 }, - { - title: '操作', - width: 120, - render: (_: any, record: any) => ( - - } - onClick={() => { - setEditingPersonal(record); - personalForm.setFieldsValue({ - studentId: record.studentId, - roomId: record.roomId, - expenseType: record.expenseType, - amount: Number(record.amount), - expenseDate: dayjs(record.expenseDate), - description: record.description, - }); - setPersonalModal(true); - }} - > - 编辑 - - { - await api.delete(`/expenses/personal/${record.id}`); - message.success('归档成功'); - fetchData(); - }} - > + const personalColumns = useMemo( + () => [ + { title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' }, + { + title: '费用类型', + width: 100, + dataIndex: 'expenseType', + render: (v: string) => {typeMap[v] || v}, + }, + { title: '金额', dataIndex: 'amount', render: (v: number) => `¥${Number(v).toFixed(2)}` }, + { title: '日期', dataIndex: 'expenseDate', width: 110 }, + { title: '说明', dataIndex: 'description', width: 150 }, + { + title: '操作', + width: 120, + render: (_: any, record: any) => ( + } + icon={} + onClick={() => { + setEditingPersonal(record); + personalForm.setFieldsValue({ + studentId: record.studentId, + roomId: record.roomId, + expenseType: record.expenseType, + amount: Number(record.amount), + expenseDate: dayjs(record.expenseDate), + description: record.description, + }); + setPersonalModal(true); + }} > - 归档 + 编辑 - - - ), - }, - ], [setEditingPersonal, personalForm, setPersonalModal, fetchData, typeMap]); + { + await api.delete(`/expenses/personal/${record.id}`); + message.success('归档成功'); + fetchData(); + }} + > + } + > + 归档 + + + + ), + }, + ], + [setEditingPersonal, personalForm, setPersonalModal, fetchData, typeMap], + ); return (
@@ -430,8 +456,8 @@ const ExpensesPage: React.FC = () => { permission="expense:view" icon={} onClick={() => { - downloadBlob('/expenses/utility/template', '水电费导入模板.xlsx').catch(() => - message.error('下载失败'), + downloadBlob('/expenses/utility/template', '水电费导入模板.xlsx').catch( + () => message.error('下载失败'), ); }} > @@ -547,9 +573,10 @@ const ExpensesPage: React.FC = () => { permission="expense:view" icon={} onClick={() => { - downloadBlob('/expenses/personal/template', '个人附加费导入模板.xlsx').catch( - () => message.error('下载失败'), - ); + downloadBlob( + '/expenses/personal/template', + '个人附加费导入模板.xlsx', + ).catch(() => message.error('下载失败')); }} > 下载模板 @@ -586,7 +613,10 @@ const ExpensesPage: React.FC = () => { } - onClick={() => { utilityForm.resetFields(); setUtilityModal(true); }} + onClick={() => { + utilityForm.resetFields(); + setUtilityModal(true); + }} > 添加学生水电费 @@ -654,7 +684,7 @@ const ExpensesPage: React.FC = () => { ({ value: student.id, label: `${student.name} (${student.studentNo || `#${student.id}`})` }))} /> + + + + + + + + + + - - + diff --git a/apps/admin/src/pages/IntegrationConfig/index.tsx b/apps/admin/src/pages/IntegrationConfig/index.tsx index 0886bef..ab017c0 100644 --- a/apps/admin/src/pages/IntegrationConfig/index.tsx +++ b/apps/admin/src/pages/IntegrationConfig/index.tsx @@ -1,12 +1,33 @@ import React, { useEffect, useState, useMemo, useCallback } from 'react'; import { - Card, Form, Input, Button, Space, Spin, Alert, Descriptions, Tag, Divider, - Drawer, Tree, Select, TreeSelect, Modal, DatePicker, - Row, Col, List, + Card, + Form, + Input, + Button, + Space, + Spin, + Alert, + Descriptions, + Tag, + Divider, + Drawer, + Tree, + Select, + TreeSelect, + Modal, + DatePicker, + Row, + Col, + List, } from 'antd'; import { - SaveOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined, - SyncOutlined, BankOutlined, UserOutlined, + SaveOutlined, + ApiOutlined, + CheckCircleOutlined, + CloseCircleOutlined, + SyncOutlined, + BankOutlined, + UserOutlined, StopOutlined, } from '@ant-design/icons'; import type { DataNode } from 'antd/es/tree'; @@ -89,7 +110,6 @@ interface DeleteAttendanceGroupsResponse { }; } - const IntegrationConfigPage: React.FC = () => { const { hasAllPermissions } = usePermission(); const [loading, setLoading] = useState(false); @@ -116,11 +136,13 @@ const IntegrationConfigPage: React.FC = () => { const [loadingGroups, setLoadingGroups] = useState(false); const [deletingGroups, setDeletingGroups] = useState(false); - const fetchConfig = async () => { setLoading(true); try { - const res = await api.get<{ success: boolean; data: Array<{ type: string; verify: boolean; config: DingTalkConfig }> }>('/integration/config'); + const res = await api.get<{ + success: boolean; + data: Array<{ type: string; verify: boolean; config: DingTalkConfig }>; + }>('/integration/config'); const dt = res.data?.find((c) => c.type === 'DINGTALK'); if (dt) { setConfig(dt.config); @@ -159,10 +181,13 @@ const IntegrationConfigPage: React.FC = () => { const payload = buildDingTalkConfigPayload(values); setTesting(true); try { - const res = await api.post<{ success: boolean; message: string }>('/integration/config/test', { - type: 'DINGTALK', - config: payload, - }); + const res = await api.post<{ success: boolean; message: string }>( + '/integration/config/test', + { + type: 'DINGTALK', + config: payload, + }, + ); setVerified(res.success); message.success(res.message); } catch (e: unknown) { @@ -174,7 +199,6 @@ const IntegrationConfigPage: React.FC = () => { } }; - const loadDeptTree = async () => { try { const res = await api.get('/sync/dingtalk/org-tree'); @@ -200,7 +224,9 @@ const IntegrationConfigPage: React.FC = () => { } else { setClasses(res.data ?? []); } - } catch { /* ignore */ } + } catch { + /* ignore */ + } }; const handleFetchOrgTree = async () => { @@ -208,7 +234,9 @@ const IntegrationConfigPage: React.FC = () => { try { const params: Record = {}; if (syncRootDeptId) params.rootDeptId = String(syncRootDeptId); - const res = await api.get('/sync/dingtalk/org-tree-with-users', { params }); + const res = await api.get('/sync/dingtalk/org-tree-with-users', { + params, + }); if (res.success && res.data) { setOrgTree(res.data); setCheckedKeys([]); @@ -226,7 +254,6 @@ const IntegrationConfigPage: React.FC = () => { } }; - const buildTreeData = useCallback((nodes: DingOrgTreeNodeExt[]): DataNode[] => { return nodes.map((node) => { const users = node.users ?? []; @@ -262,7 +289,11 @@ const IntegrationConfigPage: React.FC = () => { const treeData = useMemo(() => buildTreeData(orgTree), [orgTree, buildTreeData]); - const extractCheckedUsers = useCallback((): Array<{ dingUserId: string; name: string; mobile?: string }> => { + const extractCheckedUsers = useCallback((): Array<{ + dingUserId: string; + name: string; + mobile?: string; + }> => { const result: Array<{ dingUserId: string; name: string; mobile?: string }> = []; const walk = (nodes: DingOrgTreeNodeExt[]) => { for (const node of nodes) { @@ -285,9 +316,13 @@ const IntegrationConfigPage: React.FC = () => { setImporting(true); try { - const res = await api.post(`/classes/${selectedClassId}/students/import`, { users }); + const res = await api.post(`/classes/${selectedClassId}/students/import`, { + users, + }); if (res.conflicts > 0) { - message.warning(`导入 ${res.imported} 人,跳过 ${res.skipped} 人,${res.conflicts} 人需人工绑定`); + message.warning( + `导入 ${res.imported} 人,跳过 ${res.skipped} 人,${res.conflicts} 人需人工绑定`, + ); } else { message.success(`导入 ${res.imported} 人,跳过 ${res.skipped} 人`); } @@ -354,175 +389,206 @@ const IntegrationConfigPage: React.FC = () => { } }; - const syncPanel = config && hasAllPermissions('sync:read', 'class:view', 'class:edit') - ? ( -
- + const syncPanel = + config && hasAllPermissions('sync:read', 'class:view', 'class:edit') ? ( +
+ + + setSyncRootDeptId(v)} + placeholder="选择起始部门(不选=全部)" + allowClear + treeDefaultExpandAll + style={{ minWidth: 240 }} + onDropdownVisibleChange={(open) => { + if (open) loadDeptTree(); + }} + /> + + } + loading={loadingGroups} + onClick={openDeleteAllGroups} + > + 清空钉钉全部考勤组 + + + + {drawerOpen && ( + { + setDrawerOpen(false); + }} + width="min(900px, 100vw)" + footer={ - setSyncRootDeptId(v)} - placeholder="选择起始部门(不选=全部)" - allowClear - treeDefaultExpandAll - style={{ minWidth: 240 }} - onDropdownVisibleChange={(open) => { if (open) loadDeptTree(); }} - /> + - } - loading={loadingGroups} - onClick={openDeleteAllGroups} + - - {drawerOpen && ( - { setDrawerOpen(false); }} - width={900} - footer={ - - - - - + } + > + +
+
+ setCheckedKeys(checked as React.Key[])} + /> +
+ + + setClassModalOpen(true)}> + + 创建班级 + } > - - -
- setCheckedKeys(checked as React.Key[])} + ( + setSelectedClassId(cls.id)} + style={{ + cursor: 'pointer', + background: selectedClassId === cls.id ? '#e6f4ff' : undefined, + borderRadius: 4, + padding: '8px 12px', + }} + > + -
- - - setClassModalOpen(true)}>+ 创建班级}> - ( - setSelectedClassId(cls.id)} - style={{ - cursor: 'pointer', - background: selectedClassId === cls.id ? '#e6f4ff' : undefined, - borderRadius: 4, - padding: '8px 12px', - }} - > - - - )} - /> - - - + + )} + /> + + + - { /* Create class Modal */ } - { setClassModalOpen(false); classForm.resetFields(); }} - confirmLoading={importing} - destroyOnClose - > -
- - - - - - - - + + + + + + } placeholder="用户名" /> - + } placeholder="密码" /> diff --git a/apps/admin/src/pages/Notifications/index.tsx b/apps/admin/src/pages/Notifications/index.tsx index 4d1990b..9dc152a 100644 --- a/apps/admin/src/pages/Notifications/index.tsx +++ b/apps/admin/src/pages/Notifications/index.tsx @@ -60,7 +60,7 @@ const NotificationsPage: React.FC = () => { const fetchData = async () => { setLoading(true); try { - const data = await api.get('/notifications?limit=50') as unknown as NotificationItem[]; + const data = (await api.get('/notifications?limit=50')) as unknown as NotificationItem[]; setNotifications(data); } catch (e: any) { console.error('加载通知失败', e); @@ -91,18 +91,15 @@ const NotificationsPage: React.FC = () => { const handleMarkAll = async () => { try { await api.put('/notifications/read-all'); - setNotifications((prev) => - prev.map((n) => ({ ...n, isRead: true })), - ); + setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true }))); } catch (e: any) { console.error('全部已读失败', e); message.error(e?.message || '操作失败'); } }; - const filtered = filter === 'all' - ? notifications - : notifications.filter((n) => n.type === filter); + const filtered = + filter === 'all' ? notifications : notifications.filter((n) => n.type === filter); const filterItems = [ { key: 'all', icon: , label: '全部' }, @@ -126,7 +123,9 @@ const NotificationsPage: React.FC = () => { )}
- 通知中心 + + 通知中心 +
{isMobile && ( @@ -181,10 +180,7 @@ const NotificationsPage: React.FC = () => { } title={ - + {formatNotificationText(item.title)} diff --git a/apps/admin/src/pages/Occupancies/index.tsx b/apps/admin/src/pages/Occupancies/index.tsx index 416fbbc..06d0644 100644 --- a/apps/admin/src/pages/Occupancies/index.tsx +++ b/apps/admin/src/pages/Occupancies/index.tsx @@ -26,13 +26,13 @@ import { DownloadOutlined, ExportOutlined, } from '@ant-design/icons'; -import dayjs from 'dayjs'; +import dayjs, { type Dayjs } from 'dayjs'; import api from '../../api'; import { downloadBlob } from '../../utils/download'; import { maskPhone, maskIdNumber } from '../../utils/sensitive'; import PermissionButton from '../../components/PermissionButton'; import { message } from '../../ui/app-message'; -import { buildTransferPayload } from './occupancy-form'; +import { buildCheckInPayload, buildTransferPayload } from './occupancy-form'; const { RangePicker } = DatePicker; @@ -59,14 +59,73 @@ const OccupanciesPage: React.FC = () => { const [batchCheckOutForm] = Form.useForm(); const [availableBeds, setAvailableBeds] = useState([]); const [availableLockers, setAvailableLockers] = useState([]); + const [availableResourcesLoading, setAvailableResourcesLoading] = useState(false); const [transferAvailableBeds, setTransferAvailableBeds] = useState([]); const [transferAvailableLockers, setTransferAvailableLockers] = useState([]); + const [transferResourcesLoading, setTransferResourcesLoading] = useState(false); + const selectedCheckInRoomId = Form.useWatch('roomId', checkInForm); + const selectedTransferRoomId = Form.useWatch('newRoomId', transferForm); + + const activeOccupancyByStudentId = useMemo(() => { + const map = new Map(); + data.forEach((item) => { + if (!item.checkOutDate && item.status !== 'archived') map.set(item.studentId, item); + }); + return map; + }, [data]); + + const isRoomSelectable = useCallback((room: any) => { + const currentCount = Number(room.currentCount || 0); + const capacity = Number(room.capacity || 0); + return room.status !== 'archived' && room.status !== 'maintenance' && currentCount < capacity; + }, []); + + const roomOptionLabel = useCallback((room: any) => { + const base = `${room.roomNumber} (${room.building || ''}) [${room.currentCount}/${room.capacity}]`; + if (room.status === 'maintenance') return `${base} · 维修中`; + if (room.status === 'archived') return `${base} · 已归档`; + if (Number(room.currentCount || 0) >= Number(room.capacity || 0)) return `${base} · 已满`; + return base; + }, []); + + const selectedBatchRecords = useMemo( + () => data.filter((item) => selectedRowKeys.includes(item.id) && !item.checkOutDate), + [data, selectedRowKeys], + ); + const latestSelectedCheckInDate = useMemo( + () => selectedBatchRecords.map((item) => item.checkInDate).filter(Boolean).sort().at(-1), + [selectedBatchRecords], + ); + const latestSelectedBillingStartDate = useMemo( + () => + selectedBatchRecords + .map((item) => item.billingStartDate || item.checkInDate) + .filter(Boolean) + .sort() + .at(-1), + [selectedBatchRecords], + ); + + const dateNotBefore = (start: string | Dayjs | null | undefined, messageText: string) => + (_: unknown, value?: Dayjs | null) => { + if (!value || !start) return Promise.resolve(); + const startDate = dayjs.isDayjs(start) ? start : dayjs(start); + return value.isBefore(startDate, 'day') + ? Promise.reject(new Error(messageText)) + : Promise.resolve(); + }; const fetchData = useCallback(async () => { setLoading(true); try { const [occRes, stuRes, rmRes] = (await Promise.allSettled([ - api.get('/occupancies', { params: { active: showActive ? 'true' : undefined, dateFrom: dateRange?.[0]?.format('YYYY-MM-DD'), dateTo: dateRange?.[1]?.format('YYYY-MM-DD') } }), + api.get('/occupancies', { + params: { + active: showActive ? 'true' : undefined, + dateFrom: dateRange?.[0]?.format('YYYY-MM-DD'), + dateTo: dateRange?.[1]?.format('YYYY-MM-DD'), + }, + }), api.get('/students/basic-lookups'), api.get('/rooms/overview'), ])) as PromiseSettledResult[]; @@ -94,11 +153,11 @@ const OccupanciesPage: React.FC = () => { const handleRoomChange = async (roomId: number) => { checkInForm.setFieldValue('bedId', undefined); checkInForm.setFieldValue('lockerId', undefined); - if (!roomId) { - setAvailableBeds([]); - setAvailableLockers([]); - return; - } + setAvailableBeds([]); + setAvailableLockers([]); + if (!roomId) return; + + setAvailableResourcesLoading(true); try { const [beds, lockers] = await Promise.all([ api.get(`/rooms/${roomId}/beds/available`), @@ -107,17 +166,25 @@ const OccupanciesPage: React.FC = () => { setAvailableBeds(beds); setAvailableLockers(lockers); if (beds.length === 1) checkInForm.setFieldValue('bedId', beds[0].id); - } catch (e) { console.error(e); } + if (beds.length === 0) message.warning('该宿舍暂无可用床位,请先在宿舍详情添加或释放床位'); + } catch (e: any) { + console.error(e); + setAvailableBeds([]); + setAvailableLockers([]); + message.error(e?.message || '宿舍床位和柜子加载失败'); + } finally { + setAvailableResourcesLoading(false); + } }; const handleTransferRoomChange = async (roomId: number) => { transferForm.setFieldValue('newBedId', undefined); transferForm.setFieldValue('newLockerId', undefined); - if (!roomId) { - setTransferAvailableBeds([]); - setTransferAvailableLockers([]); - return; - } + setTransferAvailableBeds([]); + setTransferAvailableLockers([]); + if (!roomId) return; + + setTransferResourcesLoading(true); try { const [beds, lockers] = await Promise.all([ api.get(`/rooms/${roomId}/beds/available`), @@ -126,11 +193,14 @@ const OccupanciesPage: React.FC = () => { setTransferAvailableBeds(beds); setTransferAvailableLockers(lockers); if (beds.length === 1) transferForm.setFieldValue('newBedId', beds[0].id); - } catch (e) { + if (beds.length === 0) message.warning('目标宿舍暂无可用床位,请先在宿舍详情添加或释放床位'); + } catch (e: any) { console.error(e); setTransferAvailableBeds([]); setTransferAvailableLockers([]); - message.error('目标宿舍床位和柜子加载失败'); + message.error(e?.message || '目标宿舍床位和柜子加载失败'); + } finally { + setTransferResourcesLoading(false); } }; @@ -148,18 +218,7 @@ const OccupanciesPage: React.FC = () => { const values = await checkInForm.validateFields(); setSaving(true); try { - await api.post('/occupancies/check-in', { - studentId: values.studentId, - roomId: values.roomId, - checkInDate: values.checkInDate.format('YYYY-MM-DD'), - billingStartDate: values.billingStartDate?.format('YYYY-MM-DD'), - stayType: values.stayType, - collectDeposit: values.collectDeposit, - depositAmount: values.collectDeposit ? values.depositAmount : undefined, - notes: values.notes, - bedId: values.bedId, - lockerId: values.lockerId || undefined, - }); + await api.post('/occupancies/check-in', buildCheckInPayload(values)); message.success('入住登记成功'); setCheckInModal(false); checkInForm.resetFields(); @@ -195,10 +254,7 @@ const OccupanciesPage: React.FC = () => { const values = await transferForm.validateFields(); setSaving(true); try { - await api.put( - `/occupancies/${transferModal.id}/transfer`, - buildTransferPayload(values), - ); + await api.put(`/occupancies/${transferModal.id}/transfer`, buildTransferPayload(values)); message.success('换房成功'); setTransferModal(null); transferForm.resetFields(); @@ -246,83 +302,104 @@ const OccupanciesPage: React.FC = () => { } }; - const columns = useMemo(() => [ - { title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' }, - { title: '宿舍', width: 120, render: (_: any, r: any) => r.room?.roomNumber || '-' }, - { title: '床位', width: 80, render: (_: unknown, r: Record) => (r.bed as Record | undefined)?.bedNumber || '-' }, - { title: '柜子', width: 80, render: (_: unknown, r: Record) => (r.locker as Record | undefined)?.lockerNumber || '-' }, - { title: '入住日期', dataIndex: 'checkInDate', width: 110 }, - { title: '计费起始', dataIndex: 'billingStartDate', width: 110 }, - { - title: '退宿日期', - dataIndex: 'checkOutDate', - width: 110, - render: (v: any) => v || 在住, - }, - { title: '计费截止', dataIndex: 'billingEndDate', render: (v: any) => v || '-' }, - { title: '退宿原因', dataIndex: 'checkOutReason', render: (v: any) => v || '-' }, - { - title: '操作', - width: 220, - render: (_: any, record: any) => - !record.checkOutDate ? ( - - } - onClick={() => { - setCheckOutModal(record); - checkOutForm.setFieldsValue({ checkOutDate: dayjs() }); - }} - > - 退宿 - - } - onClick={() => { - setTransferAvailableBeds([]); - setTransferAvailableLockers([]); - transferForm.resetFields(); - setTransferModal(record); - transferForm.setFieldsValue({ transferDate: dayjs() }); - }} - > - 换房 - - - ) : ( - - 已退宿 - { - try { - await api.delete(`/occupancies/${record.id}`); - message.success('归档成功'); - fetchData(); - } catch (e: any) { - message.error(e?.message || '归档失败'); - } - }} - > - }> - 归档 + const columns = useMemo( + () => [ + { title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' }, + { title: '宿舍', width: 120, render: (_: any, r: any) => r.room?.roomNumber || '-' }, + { + title: '床位', + width: 80, + render: (_: unknown, r: Record) => + (r.bed as Record | undefined)?.bedNumber || '-', + }, + { + title: '柜子', + width: 80, + render: (_: unknown, r: Record) => + (r.locker as Record | undefined)?.lockerNumber || '-', + }, + { title: '入住日期', dataIndex: 'checkInDate', width: 110 }, + { title: '计费起始', dataIndex: 'billingStartDate', width: 110 }, + { + title: '退宿日期', + dataIndex: 'checkOutDate', + width: 110, + render: (v: any) => v || 在住, + }, + { title: '计费截止', dataIndex: 'billingEndDate', render: (v: any) => v || '-' }, + { title: '退宿原因', dataIndex: 'checkOutReason', render: (v: any) => v || '-' }, + { + title: '操作', + width: 220, + render: (_: any, record: any) => + !record.checkOutDate ? ( + + } + onClick={() => { + setCheckOutModal(record); + checkOutForm.setFieldsValue({ checkOutDate: dayjs() }); + }} + > + 退宿 - - - ), - }, - ], [fetchData, setCheckOutModal, checkOutForm, setTransferModal, transferForm]); + } + onClick={() => { + setTransferAvailableBeds([]); + setTransferAvailableLockers([]); + transferForm.resetFields(); + setTransferModal(record); + transferForm.setFieldsValue({ transferDate: dayjs() }); + }} + > + 换房 + + + ) : ( + + 已退宿 + { + try { + await api.delete(`/occupancies/${record.id}`); + message.success('归档成功'); + fetchData(); + } catch (e: any) { + message.error(e?.message || '归档失败'); + } + }} + > + } + > + 归档 + + + + ), + }, + ], + [fetchData, setCheckOutModal, checkOutForm, setTransferModal, transferForm], + ); - const rowSelection = useMemo(() => ({ - selectedRowKeys, - onChange: (keys: any[]) => setSelectedRowKeys(keys), - // 「在住记录」Tab:禁用已退宿(防止误选用于批量退宿);「全部记录」Tab:均可选用于批量归档 - getCheckboxProps: (record: any) => (showActive ? { disabled: !!record.checkOutDate } : {}), - }), [selectedRowKeys, showActive]); + const rowSelection = useMemo( + () => ({ + selectedRowKeys, + onChange: (keys: any[]) => setSelectedRowKeys(keys), + // 「在住记录」Tab:禁用已退宿(防止误选用于批量退宿);「全部记录」Tab:均可选用于批量归档 + getCheckboxProps: (record: any) => (showActive ? { disabled: !!record.checkOutDate } : {}), + }), + [selectedRowKeys, showActive], + ); return (
@@ -348,7 +425,14 @@ const OccupanciesPage: React.FC = () => { allowClear style={{ width: 200 }} /> - { setDateRange(dates ? [dates[0], dates[1]] : null); }} placeholder={['入住开始', '入住结束']} style={{ width: 240 }} /> + { + setDateRange(dates ? [dates[0], dates[1]] : null); + }} + placeholder={['入住开始', '入住结束']} + style={{ width: 240 }} + /> { icon={} onClick={() => { checkInForm.resetFields(); - checkInForm.setFieldsValue({ checkInDate: dayjs(), collectDeposit: true, depositAmount: 500 }); + setAvailableBeds([]); + setAvailableLockers([]); + setAvailableResourcesLoading(false); + const today = dayjs(); + checkInForm.setFieldsValue({ + checkInDate: today, + billingStartDate: today, + stayType: 'short', + collectDeposit: true, + depositAmount: 500, + }); setCheckInModal(true); }} > @@ -432,26 +526,26 @@ const OccupanciesPage: React.FC = () => { 导入时自动收押金 {autoDeposit && ( - setDepositAmount(v || 500)} - style={{ width: 60 }} - /> - - 元 - - + setDepositAmount(v || 500)} + style={{ width: 60 }} + /> + + 元 + + )} @@ -524,7 +618,12 @@ const OccupanciesPage: React.FC = () => { title="入住登记" open={checkInModal} onOk={handleCheckIn} - onCancel={() => setCheckInModal(false)} + onCancel={() => { + setCheckInModal(false); + setAvailableBeds([]); + setAvailableLockers([]); + setAvailableResourcesLoading(false); + }} okText="确认入住" confirmLoading={saving} width={500} @@ -541,10 +640,15 @@ const OccupanciesPage: React.FC = () => { placeholder="搜索并选择学生" options={students .filter((s: any) => s.status === 'active') - .map((s: any) => ({ - value: s.id, - label: `${s.name} (${s.idNumber ? maskIdNumber(s.idNumber) : (s.phone ? maskPhone(s.phone) : '')})`, - }))} + .map((s: any) => { + const activeOccupancy = activeOccupancyByStudentId.get(s.id); + const identifier = s.idNumber ? maskIdNumber(s.idNumber) : s.phone ? maskPhone(s.phone) : ''; + return { + value: s.id, + label: `${s.name} (${identifier})${activeOccupancy ? ` · 已入住${activeOccupancy.room?.roomNumber ? ` ${activeOccupancy.room.roomNumber}` : ''}` : ''}`, + disabled: !!activeOccupancy, + }; + })} /> { onChange={handleRoomChange} options={rooms.map((r) => ({ value: r.id, - label: `${r.roomNumber} (${r.building || ''}) [${r.currentCount}/${r.capacity}]`, - disabled: r.currentCount >= r.capacity, + label: roomOptionLabel(r), + disabled: !isRoomSelectable(r), }))} /> - + ({ + validator: dateNotBefore(getFieldValue('checkInDate'), '计费起始日不能早于入住日期'), + }), + ]} > { format="YYYY-MM-DD" /> - + ({ value: b.id, label: b.bedNumber, }))} - notFoundContent="该房间暂无可用床位" + notFoundContent={selectedCheckInRoomId ? '该房间暂无可用床位' : '请先选择房间'} /> {availableBeds.length > 0 && ( @@ -608,14 +727,12 @@ const OccupanciesPage: React.FC = () => { 空闲 {availableBeds.length} 张床位
)} - + ({ value: bed.id, label: bed.bedNumber, }))} - notFoundContent="目标宿舍暂无可用床位" + notFoundContent={selectedTransferRoomId ? '目标宿舍暂无可用床位' : '请先选择目标宿舍'} /> {transferAvailableBeds.length > 0 && ( @@ -797,7 +965,8 @@ const OccupanciesPage: React.FC = () => { + { { setDrawerOpen(false); setDrawerRoom(null); }} + onClose={() => { + setDrawerOpen(false); + setDrawerRoom(null); + }} width={640} destroyOnClose > @@ -624,14 +680,40 @@ const RoomsPage: React.FC = () => { label: '基本信息', children: drawerRoom && (
-
房间号:{drawerRoom.roomNumber}
-
楼栋:{drawerRoom.building || '-'}
-
楼层:{drawerRoom.floor ?? '-'}
-
类型:{drawerRoom.roomType || '-'}
-
额定人数:{drawerRoom.capacity}
-
租赁类别:{drawerRoom.rentalCategory === 'long' ? '长租' : '短租'}
-
月租金:{drawerRoom.monthlyRate ? `¥${drawerRoom.monthlyRate}` : '-'}
-
状态:{statusMap[drawerRoom.status]?.text}
+
+ 房间号: + {drawerRoom.roomNumber} +
+
+ 楼栋: + {drawerRoom.building || '-'} +
+
+ 楼层: + {drawerRoom.floor ?? '-'} +
+
+ 类型: + {drawerRoom.roomType || '-'} +
+
+ 额定人数: + {drawerRoom.capacity} +
+
+ 租赁类别: + {drawerRoom.rentalCategory === 'long' ? '长租' : '短租'} +
+
+ 月租金: + {drawerRoom.monthlyRate ? `¥${drawerRoom.monthlyRate}` : '-'} +
+
+ 状态: + + {statusMap[drawerRoom.status]?.text} + +
), }, @@ -646,25 +728,48 @@ const RoomsPage: React.FC = () => { size="small" icon={} disabled={drawerRoom?.status === 'archived' || remainingBedSlots === 0} - onClick={() => { setBedEditing(null); bedForm.resetFields(); setBedModalOpen(true); }} + onClick={() => { + setBedEditing(null); + bedForm.resetFields(); + setBedModalOpen(true); + }} > 添加床位 0 ? '批量生成床位' : '床位已达到额定人数'} description={ - remainingBedSlots > 0 - ? - : '如需增加床位,请先调整宿舍额定人数' + remainingBedSlots > 0 ? ( + + ) : ( + '如需增加床位,请先调整宿舍额定人数' + ) } onConfirm={() => { - const input = document.getElementById('batch-bed-count') as HTMLInputElement; - handleBatchBeds(input ? parseInt(input.value) || defaultBatchBedCount : defaultBatchBedCount); + const input = document.getElementById( + 'batch-bed-count', + ) as HTMLInputElement; + handleBatchBeds( + input + ? parseInt(input.value) || defaultBatchBedCount + : defaultBatchBedCount, + ); }} okText="生成" disabled={drawerRoom?.status === 'archived' || remainingBedSlots === 0} > - +
{ columns={[ { title: '编号', dataIndex: 'bedNumber', width: 80 }, { - title: '状态', dataIndex: 'status', width: 80, + title: '状态', + dataIndex: 'status', + width: 80, render: (s: string) => { const map: Record = { available: { text: '空闲', color: 'green' }, @@ -687,7 +794,8 @@ const RoomsPage: React.FC = () => { }, { title: '备注', dataIndex: 'notes', render: (v: string) => v || '-' }, { - title: '操作', width: 120, + title: '操作', + width: 120, render: (_: any, r: any) => ( { size="small" type="link" disabled={drawerRoom?.status === 'archived'} - onClick={() => { setBedEditing(r); bedForm.setFieldsValue(r); setBedModalOpen(true); }} + onClick={() => { + setBedEditing(r); + bedForm.setFieldsValue(r); + setBedModalOpen(true); + }} > 编辑 {r.status !== 'occupied' && ( - handleDeleteBed(r.id)}> + handleDeleteBed(r.id)} + > { size="small" icon={} disabled={drawerRoom?.status === 'archived'} - onClick={() => { setLockerEditing(null); lockerForm.resetFields(); setLockerModalOpen(true); }} + onClick={() => { + setLockerEditing(null); + lockerForm.resetFields(); + setLockerModalOpen(true); + }} > 添加柜子 + } onConfirm={() => { - const input = document.getElementById('batch-locker-count') as HTMLInputElement; + const input = document.getElementById( + 'batch-locker-count', + ) as HTMLInputElement; handleBatchLockers(input ? parseInt(input.value) || 4 : 4); }} okText="生成" disabled={drawerRoom?.status === 'archived'} > - +
{ columns={[ { title: '编号', dataIndex: 'lockerNumber', width: 80 }, { - title: '状态', dataIndex: 'status', width: 80, + title: '状态', + dataIndex: 'status', + width: 80, render: (s: string) => { const map: Record = { available: { text: '空闲', color: 'green' }, @@ -770,7 +901,8 @@ const RoomsPage: React.FC = () => { }, { title: '备注', dataIndex: 'notes', render: (v: string) => v || '-' }, { - title: '操作', width: 120, + title: '操作', + width: 120, render: (_: any, r: any) => ( { size="small" type="link" disabled={drawerRoom?.status === 'archived'} - onClick={() => { setLockerEditing(r); lockerForm.setFieldsValue(r); setLockerModalOpen(true); }} + onClick={() => { + setLockerEditing(r); + lockerForm.setFieldsValue(r); + setLockerModalOpen(true); + }} > 编辑 {r.status !== 'occupied' && ( - handleDeleteLocker(r.id)}> + handleDeleteLocker(r.id)} + > { title={bedEditing ? '编辑床位' : '添加床位'} open={bedModalOpen} onOk={handleSaveBed} - onCancel={() => { setBedModalOpen(false); setBedEditing(null); }} + onCancel={() => { + setBedModalOpen(false); + setBedEditing(null); + }} confirmLoading={savingBed} okText="保存" > @@ -838,7 +980,10 @@ const RoomsPage: React.FC = () => { title={lockerEditing ? '编辑柜子' : '添加柜子'} open={lockerModalOpen} onOk={handleSaveLocker} - onCancel={() => { setLockerModalOpen(false); setLockerEditing(null); }} + onCancel={() => { + setLockerModalOpen(false); + setLockerEditing(null); + }} confirmLoading={savingLocker} okText="保存" > diff --git a/apps/admin/src/pages/Schedules/index.tsx b/apps/admin/src/pages/Schedules/index.tsx index 6913188..ce448cb 100644 --- a/apps/admin/src/pages/Schedules/index.tsx +++ b/apps/admin/src/pages/Schedules/index.tsx @@ -478,7 +478,6 @@ const SchedulesPage: React.FC = () => { } }; - // ---- Classroom select options ---- const classroomOptions = useMemo( @@ -1192,17 +1191,17 @@ const SchedulesPage: React.FC = () => { {syncResult ? ( /* ── 同步结果 ── */
- -
+ + - + - + - + { ) : syncStatus ? ( /* ── 同步确认信息 ── */
- -
+ + - + { }} /> - + { }); }); - describe('schedule notes normalization', () => { it('omits whitespace-only notes from the payload', () => { expect( diff --git a/apps/admin/src/pages/Students/index.tsx b/apps/admin/src/pages/Students/index.tsx index f14847c..8b2c0ea 100644 --- a/apps/admin/src/pages/Students/index.tsx +++ b/apps/admin/src/pages/Students/index.tsx @@ -62,7 +62,6 @@ interface EnrollmentInfo { }; } - interface StudentCreateImportResult { message?: string; imported?: number; @@ -75,6 +74,11 @@ interface StudentUpdateImportResult { skipped?: number; } +interface StudentFilterLookups { + classes: Array<{ id: number; name: string; code?: string }>; + teachers: Array<{ id: number; name: string; username: string }>; +} + const StudentsPage: React.FC = () => { const { modal } = App.useApp(); const [data, setData] = useState([]); @@ -85,6 +89,10 @@ const StudentsPage: React.FC = () => { const [searchName, setSearchName] = useState(''); const [filterStatus, setFilterStatus] = useState(undefined); const [filterOrganizationId, setFilterOrganizationId] = useState(undefined); + const [filterClassId, setFilterClassId] = useState(undefined); + const [filterTeacherId, setFilterTeacherId] = useState(undefined); + const [classOptions, setClassOptions] = useState([]); + const [teacherOptions, setTeacherOptions] = useState([]); const [showArchived, setShowArchived] = useState(false); const [archivedCount, setArchivedCount] = useState(0); const [selectedRowKeys, setSelectedRowKeys] = useState([]); @@ -150,6 +158,8 @@ const StudentsPage: React.FC = () => { }; if (filterStatus) params.status = filterStatus; if (filterOrganizationId) params.organizationId = filterOrganizationId; + if (filterClassId) params.classId = filterClassId; + if (filterTeacherId) params.teacherId = filterTeacherId; const res = (await api.get('/students', { params })) as Array>; const list = res as Array>; const archived = list.filter((r) => r.status === 'archived'); @@ -160,7 +170,7 @@ const StudentsPage: React.FC = () => { message.error(err?.message || '加载失败,请稍后重试'); } setLoading(false); - }, [searchName, showArchived, filterStatus, filterOrganizationId]); + }, [searchName, showArchived, filterStatus, filterOrganizationId, filterClassId, filterTeacherId]); useEffect(() => { fetchData(); @@ -173,6 +183,13 @@ const StudentsPage: React.FC = () => { setOrganizations(res as Array<{ id: number; name: string }>); }) .catch(() => {}); + api + .get('/students/filter-lookups') + .then((res) => { + setClassOptions(res.classes || []); + setTeacherOptions(res.teachers || []); + }) + .catch(() => {}); }, []); const handleSave = async () => { const values = await form.validateFields(); @@ -330,8 +347,15 @@ const StudentsPage: React.FC = () => { ? '/api' : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`; const token = localStorage.getItem('token'); - const params = showArchived ? '?includeArchived=true' : ''; - fetch(`${baseURL}/students/export${params}`, { headers: { Authorization: `Bearer ${token}` } }) + const params = new URLSearchParams(); + if (searchName) params.set('name', searchName); + if (filterStatus) params.set('status', filterStatus); + if (filterOrganizationId) params.set('organizationId', String(filterOrganizationId)); + if (showArchived) params.set('includeArchived', 'true'); + if (filterClassId) params.set('classId', String(filterClassId)); + if (filterTeacherId) params.set('teacherId', String(filterTeacherId)); + const query = params.toString() ? `?${params.toString()}` : ''; + fetch(`${baseURL}/students/export${query}`, { headers: { Authorization: `Bearer ${token}` } }) .then((res) => res.blob()) .then((blob) => { const url = URL.createObjectURL(blob); @@ -568,6 +592,36 @@ const StudentsPage: React.FC = () => { ))} + { + setFilterTeacherId(v); + }} + options={teacherOptions.map((item) => ({ + value: item.id, + label: item.name === item.username ? item.name : `${item.name}(${item.username})`, + }))} + /> { } return ( - + {enrollments.map((enr, idx) => ( - + { fetchData(); }, []); - const classColumns: ColumnsType = useMemo(() => [ - { - title: '班级名称', - dataIndex: 'className', - render: (v: string, r: AssignedClass) => `${v} (${r.classCode})`, - }, - { - title: '角色', - dataIndex: 'roleType', - render: (v: string) => {ROLE_LABELS[v] || v}, - }, - { - title: '科目', - dataIndex: 'subject', - render: (v: string | null) => v || '-', - }, - ], []); + const classColumns: ColumnsType = useMemo( + () => [ + { + title: '班级名称', + dataIndex: 'className', + render: (v: string, r: AssignedClass) => `${v} (${r.classCode})`, + }, + { + title: '角色', + dataIndex: 'roleType', + render: (v: string) => {ROLE_LABELS[v] || v}, + }, + { + title: '科目', + dataIndex: 'subject', + render: (v: string | null) => v || '-', + }, + ], + [], + ); - const scheduleColumns: ColumnsType = useMemo(() => [ - { - title: '时间', - key: 'time', - render: (_: unknown, r: ScheduleItem) => `${r.startTime} - ${r.endTime}`, - }, - { - title: '星期', - dataIndex: 'weekDay', - render: (v: number) => {WEEKDAY_LABELS[String(v)] || v}, - }, - { - title: '科目', - dataIndex: 'subject', - }, - { - title: '类型', - dataIndex: 'scheduleType', - render: (v: string) => ( - - {v === 'INTERNAL' ? '内部课程' : '租赁'} - - ), - }, - ], []); + const scheduleColumns: ColumnsType = useMemo( + () => [ + { + title: '时间', + key: 'time', + render: (_: unknown, r: ScheduleItem) => `${r.startTime} - ${r.endTime}`, + }, + { + title: '星期', + dataIndex: 'weekDay', + render: (v: number) => {WEEKDAY_LABELS[String(v)] || v}, + }, + { + title: '科目', + dataIndex: 'subject', + }, + { + title: '类型', + dataIndex: 'scheduleType', + render: (v: string) => ( + + {v === 'INTERNAL' ? '内部课程' : '租赁'} + + ), + }, + ], + [], + ); - const studentColumns: ColumnsType = useMemo(() => [ - { title: '姓名', dataIndex: 'studentName' }, - { title: '学号', dataIndex: 'studentNo', render: (v: string) => v || '-' }, - { title: '班级', dataIndex: 'className' }, - { title: '加入日期', dataIndex: 'joinDate', render: (v: string) => v || '-' }, - ], []); + const studentColumns: ColumnsType = useMemo( + () => [ + { title: '姓名', dataIndex: 'studentName' }, + { title: '学号', dataIndex: 'studentNo', render: (v: string) => v || '-' }, + { title: '班级', dataIndex: 'className' }, + { title: '加入日期', dataIndex: 'joinDate', render: (v: string) => v || '-' }, + ], + [], + ); if (loading) { return ( diff --git a/apps/admin/src/pages/Teachers/index.tsx b/apps/admin/src/pages/Teachers/index.tsx index bf39d45..c7cb9cd 100644 --- a/apps/admin/src/pages/Teachers/index.tsx +++ b/apps/admin/src/pages/Teachers/index.tsx @@ -101,87 +101,94 @@ const TeachersPage: React.FC = () => { } }; - const columns = useMemo(() => [ - { title: '姓名', dataIndex: 'name', key: 'name', width: 120 }, - { title: '用户名', dataIndex: 'username', key: 'username', width: 130 }, - { - title: '角色', - dataIndex: 'roles', - key: 'roles', - width: 220, - render: (roles: TeacherRow['roles']) => - roles.map((r) => {ROLE_LABELS[r.code] || r.name}), - }, - { - title: '任课班级', - dataIndex: 'classAssignments', - key: 'classes', - width: 200, - render: (ca: TeacherRow['classAssignments']) => - ca?.length - ? ca.map((a, i) => ( - - {a.className || '-'} - {a.subject ? ` (${a.subject})` : ''} - - )) - : '-', - }, - { - title: '科目', - dataIndex: 'profile', - key: 'subjects', - width: 130, - render: (p: TeacherRow['profile']) => p?.subjects?.join('、') || '-', - }, - { - title: '入职日期', - dataIndex: 'profile', - key: 'joinedAt', - width: 110, - render: (p: TeacherRow['profile']) => p?.joinedAt || '-', - }, - { - title: '状态', - dataIndex: 'isActive', - key: 'status', - width: 90, - render: (v: boolean) => {v ? '在职' : '停用'}, - }, - { - title: '最后登录', - dataIndex: 'lastLoginAt', - key: 'login', - width: 160, - render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-'), - }, - { - title: '操作', - key: 'actions', - width: 100, - render: (_: unknown, r: TeacherRow) => ( - - ), - }, - ], []); + const columns = useMemo( + () => [ + { title: '姓名', dataIndex: 'name', key: 'name', width: 120 }, + { title: '用户名', dataIndex: 'username', key: 'username', width: 130 }, + { + title: '角色', + dataIndex: 'roles', + key: 'roles', + width: 220, + render: (roles: TeacherRow['roles']) => + roles.map((r) => {ROLE_LABELS[r.code] || r.name}), + }, + { + title: '任课班级', + dataIndex: 'classAssignments', + key: 'classes', + width: 200, + render: (ca: TeacherRow['classAssignments']) => + ca?.length + ? ca.map((a, i) => ( + + {a.className || '-'} + {a.subject ? ` (${a.subject})` : ''} + + )) + : '-', + }, + { + title: '科目', + dataIndex: 'profile', + key: 'subjects', + width: 130, + render: (p: TeacherRow['profile']) => p?.subjects?.join('、') || '-', + }, + { + title: '入职日期', + dataIndex: 'profile', + key: 'joinedAt', + width: 110, + render: (p: TeacherRow['profile']) => p?.joinedAt || '-', + }, + { + title: '状态', + dataIndex: 'isActive', + key: 'status', + width: 90, + render: (v: boolean) => {v ? '在职' : '停用'}, + }, + { + title: '最后登录', + dataIndex: 'lastLoginAt', + key: 'login', + width: 160, + render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-'), + }, + { + title: '操作', + key: 'actions', + width: 100, + render: (_: unknown, r: TeacherRow) => ( + + ), + }, + ], + [], + ); return (

教师管理

- + { const [data, setData] = useState([]); @@ -36,7 +29,6 @@ const UsersPage: React.FC = () => { const [saving, setSaving] = useState(false); const [showArchived, setShowArchived] = useState(false); - const handleOpenProfile = async (record: any) => { setProfileUser(record); try { @@ -79,7 +71,6 @@ const UsersPage: React.FC = () => { setLoading(false); }, [showArchived]); - useEffect(() => { fetchData(); }, [fetchData]); @@ -162,89 +153,99 @@ const UsersPage: React.FC = () => { } }; - const columns = useMemo(() => [ - { title: 'ID', dataIndex: 'id', width: 60 }, - { title: '用户名', dataIndex: 'username', width: 120 }, - { title: '姓名', dataIndex: 'name', width: 120 }, - { - title: '角色', - dataIndex: 'roles', - width: 200, - render: (v: any[]) => - v && v.length > 0 ? ( - v.map((r: any) => ( - - {r.name} - - )) - ) : ( - 无角色 - ), - }, - { - title: '状态', - dataIndex: 'isActive', - width: 80, - render: (v: boolean) => {v ? '启用' : '禁用'}, - }, - { - title: '最后登录', - dataIndex: 'lastLoginAt', - width: 170, - render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '-'), - }, - { - title: '创建时间', - dataIndex: 'createdAt', - width: 170, - render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm:ss'), - }, - { - title: '操作', - width: 240, - fixed: 'right' as const, - render: (_: unknown, record: any) => ( - - } - onClick={() => handleOpenProfile(record)} - > - 档案 - - } - onClick={() => handleEdit(record)} - > - 编辑 - - } - onClick={() => handleResetPwd(record)} - > - 重置密码 - - {record.isArchived ? ( - handleArchive(record.id, false)}> - 恢复 - + const columns = useMemo( + () => [ + { title: 'ID', dataIndex: 'id', width: 60 }, + { title: '用户名', dataIndex: 'username', width: 120 }, + { title: '姓名', dataIndex: 'name', width: 120 }, + { + title: '角色', + dataIndex: 'roles', + width: 200, + render: (v: any[]) => + v && v.length > 0 ? ( + v.map((r: any) => ( + + {r.name} + + )) ) : ( - handleArchive(record.id, true)}> - 归档 - - )} - - ), - }, - ], []); + 无角色 + ), + }, + { + title: '状态', + dataIndex: 'isActive', + width: 80, + render: (v: boolean) => {v ? '启用' : '禁用'}, + }, + { + title: '最后登录', + dataIndex: 'lastLoginAt', + width: 170, + render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '-'), + }, + { + title: '创建时间', + dataIndex: 'createdAt', + width: 170, + render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm:ss'), + }, + { + title: '操作', + width: 240, + fixed: 'right' as const, + render: (_: unknown, record: any) => ( + + } + onClick={() => handleOpenProfile(record)} + > + 档案 + + } + onClick={() => handleEdit(record)} + > + 编辑 + + } + onClick={() => handleResetPwd(record)} + > + 重置密码 + + {record.isArchived ? ( + handleArchive(record.id, false)}> + + 恢复 + + + ) : ( + handleArchive(record.id, true)} + > + + 归档 + + + )} + + ), + }, + ], + [], + ); return (
@@ -256,7 +257,8 @@ const UsersPage: React.FC = () => { alignItems: 'center', flexWrap: 'wrap', gap: 8, - }}> + }} + >

账号管理

{ - + ({ label: type, value: type }))} + /> + 仅看欠费 + + + + } + disabled={selectedRowKeys.length === 0} + onClick={openBatchChange} + > + 批量充值/调账 + + + +
+
`共 ${total} 人` }} + /> + setSelected(null)} + onOk={submitChange} + confirmLoading={saving} + okText="确认" + > + + + + + + + + + + + + + setBatchModalOpen(false)} + onOk={submitBatchChange} + confirmLoading={saving} + okText="确认批量修改" + > +
+
+ 已选择 {selectedRowKeys.length}{' '} + 名学生,将按相同金额批量修改水电费余额。可先按房型筛选并勾选对应学生后批量缴费。 +
+ + + + + + + + + + +
+ { + setDrawerOpen(false); + setSelected(null); + }} + > +
dayjs(value).format('YYYY-MM-DD HH:mm'), + }, + { + title: '类型', + dataIndex: 'type', + render: (value: string) => transactionNames[value] || value, + }, + { + title: '金额', + dataIndex: 'amount', + render: (value: number) => ( + = 0 ? '#389e0d' : '#cf1322' }}> + {Number(value) >= 0 ? '+' : ''}¥{Number(value).toFixed(2)} + + ), + }, + { + title: '变动后余额', + dataIndex: 'balanceAfter', + render: (value: number) => `¥${Number(value).toFixed(2)}`, + }, + { + title: '关联账单', + dataIndex: 'billId', + render: (value: number) => (value ? `#${value}` : '-'), + }, + { title: '说明', dataIndex: 'description' }, + ]} + /> + -
`共 ${total} 人` }} - /> - setSelected(null)} onOk={submitChange} confirmLoading={saving} okText="确认"> -
- - - - -
- setBatchModalOpen(false)} - onOk={submitBatchChange} - confirmLoading={saving} - okText="确认批量修改" - > -
-
- 已选择 {selectedRowKeys.length} 名学生,将按相同金额批量修改水电费余额。 -
- - - - - - - - -
- { setDrawerOpen(false); setSelected(null); }}> -
dayjs(value).format('YYYY-MM-DD HH:mm') }, - { title: '类型', dataIndex: 'type', render: (value: string) => transactionNames[value] || value }, - { title: '金额', dataIndex: 'amount', render: (value: number) => = 0 ? '#389e0d' : '#cf1322' }}>{Number(value) >= 0 ? '+' : ''}¥{Number(value).toFixed(2)} }, - { title: '变动后余额', dataIndex: 'balanceAfter', render: (value: number) => `¥${Number(value).toFixed(2)}` }, - { title: '关联账单', dataIndex: 'billId', render: (value: number) => value ? `#${value}` : '-' }, - { title: '说明', dataIndex: 'description' }, - ]} /> - - ; + ); }; export default WalletsPage; diff --git a/apps/admin/src/test/fixtures.ts b/apps/admin/src/test/fixtures.ts index fb0aa9f..a8bc120 100644 --- a/apps/admin/src/test/fixtures.ts +++ b/apps/admin/src/test/fixtures.ts @@ -202,24 +202,67 @@ export const LOG_ACTIONS = { // ── Permission nodes (PRD §17) ────────────────────────────────────── export const PERMISSION_NODES = [ - 'student:view', 'student:add', 'student:update', 'student:delete', - 'student:import', 'student:export', - 'room:view', 'room:add', 'room:update', 'room:delete', - 'occupancy:view', 'occupancy:add', 'occupancy:update', - 'bill:view', 'bill:generate', 'bill:confirm', 'bill:markPaid', 'bill:export', - 'expense:view', 'expense:add', 'expense:update', 'expense:delete', - 'deposit:view', 'deposit:collect', 'deposit:refund', - 'class:view', 'class:add', 'class:update', 'class:delete', - 'schedule:view', 'schedule:add', 'schedule:update', 'schedule:delete', - 'attendance:view', 'attendance:add', 'attendance:update', 'attendance:delete', + 'student:view', + 'student:add', + 'student:update', + 'student:delete', + 'student:import', + 'student:export', + 'room:view', + 'room:add', + 'room:update', + 'room:delete', + 'occupancy:view', + 'occupancy:add', + 'occupancy:update', + 'bill:view', + 'bill:generate', + 'bill:confirm', + 'bill:markPaid', + 'bill:export', + 'expense:view', + 'expense:add', + 'expense:update', + 'expense:delete', + 'deposit:view', + 'deposit:collect', + 'deposit:refund', + 'class:view', + 'class:add', + 'class:update', + 'class:delete', + 'schedule:view', + 'schedule:add', + 'schedule:update', + 'schedule:delete', + 'attendance:view', + 'attendance:add', + 'attendance:update', + 'attendance:delete', 'attendance:batch', - 'classroom:view', 'classroom:add', 'classroom:update', 'classroom:delete', - 'organization:view', 'organization:create', 'organization:edit', 'organization:delete', - 'rental:view', 'rental:add', 'rental:update', 'rental:delete', - 'archive:view', 'archive:import', 'archive:export', + 'classroom:view', + 'classroom:add', + 'classroom:update', + 'classroom:delete', + 'organization:view', + 'organization:create', + 'organization:edit', + 'organization:delete', + 'rental:view', + 'rental:add', + 'rental:update', + 'rental:delete', + 'archive:view', + 'archive:import', + 'archive:export', 'report:generate', 'log:view', - 'role:view', 'role:add', 'role:update', 'role:delete', - 'user:view', 'user:add', 'user:update', + 'role:view', + 'role:add', + 'role:update', + 'role:delete', + 'user:view', + 'user:add', + 'user:update', 'dashboard:view', ] as const; diff --git a/apps/admin/src/test/helpers.ts b/apps/admin/src/test/helpers.ts index 81beded..aab9506 100644 --- a/apps/admin/src/test/helpers.ts +++ b/apps/admin/src/test/helpers.ts @@ -25,7 +25,9 @@ type Role = keyof typeof CREDENTIALS; * Login as a specific role and store the token in localStorage. * Returns the parsed response data. */ -export async function loginAs(role: Role): Promise<{ token: string; user: Record }> { +export async function loginAs( + role: Role, +): Promise<{ token: string; user: Record }> { const creds = CREDENTIALS[role]; const res = await fetch(`${BASE}/api/auth/login`, { method: 'POST', diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index b86de8d..3fbb83f 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -31,6 +31,7 @@ import { AttendanceRecord, AttendanceSession, AttendanceDevice, + AttendancePeriodConfig, DingAttendanceRaw, SyncLog, SyncState, @@ -128,6 +129,7 @@ import { IntegrationConfigModule } from './integration/config/config.module'; AttendanceRecord, AttendanceSession, AttendanceDevice, + AttendancePeriodConfig, DingAttendanceRaw, Notification, StudentProfile, diff --git a/apps/server/src/attendance/attendance-settlement.service.ts b/apps/server/src/attendance/attendance-settlement.service.ts index 0c15735..d4393aa 100644 --- a/apps/server/src/attendance/attendance-settlement.service.ts +++ b/apps/server/src/attendance/attendance-settlement.service.ts @@ -113,6 +113,8 @@ export class AttendanceSettlementService { const userIds = await this.attendanceService.getTeacherClassDingUserIds( schedule.teacherId, schedule.classId, + false, + lessonDate, ); const importRange = this.attendanceService.getLessonAttendanceImportDateRange( schedule, diff --git a/apps/server/src/attendance/attendance.controller.ts b/apps/server/src/attendance/attendance.controller.ts index 6f04a1e..3a1e07c 100644 --- a/apps/server/src/attendance/attendance.controller.ts +++ b/apps/server/src/attendance/attendance.controller.ts @@ -25,6 +25,7 @@ import { AttendanceSummaryQueryDto, AttendanceCalendarQueryDto, QueryAttendanceRecordsDto, + AttendanceScheduleOptionsQueryDto, QueryDingRawDto, MatchDingRecordDto, AttendanceReportQueryDto, @@ -33,6 +34,8 @@ import { GenerateFromSchedulesDto, LessonAttendanceQueryDto, StartLessonAttendanceDto, + SaveAttendancePeriodConfigsDto, + RefreshDingTalkAttendanceDto, } from './dto/attendance.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; @@ -92,6 +95,45 @@ export class AttendanceController { return this.service.assertClassAccess(req.user.id, classId, this.canManageAllAttendance(req)); } + + @Get('attendance-period-configs') + @RequirePermission('attendance:view') + getAttendancePeriodConfigs() { + return this.service.getAttendancePeriodConfigs(); + } + + @Put('attendance-period-configs') + @RequirePermission('attendance:edit') + async saveAttendancePeriodConfigs( + @Body() dto: SaveAttendancePeriodConfigsDto, + @Request() req: { user: RequestUser }, + ) { + const result = await this.service.saveAttendancePeriodConfigs(dto); + await this.logService.log({ + userId: req.user.id, + username: req.user.username, + module: '考勤管理', + action: '保存考勤时段配置', + targetType: 'attendancePeriodConfig', + detail: dto.periods.map((item) => `${item.label}:${item.startTime}-${item.endTime}`).join(';'), + }); + return result; + } + + @Post('attendance-period-configs/reset') + @RequirePermission('attendance:edit') + async resetAttendancePeriodConfigs(@Request() req: { user: RequestUser }) { + const result = await this.service.resetAttendancePeriodConfigs(); + await this.logService.log({ + userId: req.user.id, + username: req.user.username, + module: '考勤管理', + action: '重置考勤时段配置', + targetType: 'attendancePeriodConfig', + }); + return result; + } + @Get('attendance-lessons/schedules/:scheduleId') @RequirePermission('attendance:view') async getLessonAttendance( @@ -117,6 +159,7 @@ export class AttendanceController { req.user.id, schedule.schedule.classId!, this.canManageAllAttendance(req), + dto.date, ); const importRange = this.service.getLessonAttendanceImportDateRange( schedule.schedule, @@ -128,6 +171,9 @@ export class AttendanceController { autoMatch: true, userId: req.user.id, }); + if (!importResult.success || importResult.errors.length > 0) { + throw new BadRequestException(importResult.errors.join('; ') || '钉钉考勤拉取失败'); + } const result = await this.service.createLessonAttendanceFromDingTalk( scheduleId, dto.date, @@ -166,6 +212,84 @@ export class AttendanceController { return result; } + + @Get('attendance-records/dingtalk-sync-status') + @RequirePermission('attendance:view') + async getDingTalkSyncStatus() { + const latest = await this.logService.findLatestDingTalkAttendancePull(); + return { + lastPulledAt: latest?.createdAt ?? null, + action: latest?.action ?? null, + username: latest?.username ?? null, + detail: latest?.detail ?? null, + }; + } + + @Post('attendance-records/refresh-dingtalk') + @RequirePermission('attendance:create') + async refreshDingTalkAttendance( + @Body() dto: RefreshDingTalkAttendanceDto, + @Request() req: { user: RequestUser }, + ) { + if (dto.date > this.getTodayDateOnly()) { + throw new BadRequestException('不能查看或刷新未来日期的考勤'); + } + if (dto.classId) await this.assertClassAccess(req, dto.classId); + const schedules = await this.service.getRefreshableSchedules( + dto.date, + dto.classId, + dto.session, + await this.getAccessibleClassIds(req), + ); + let refreshed = 0; + let imported = 0; + let matched = 0; + const errors: string[] = []; + + for (const schedule of schedules) { + try { + const importClassIds = await this.service.getTeacherClassDingUserIds( + req.user.id, + schedule.classId!, + this.canManageAllAttendance(req), + dto.date, + ); + const importRange = this.service.getLessonAttendanceImportDateRange(schedule, dto.date); + const importResult = await this.importService.importFromDingTalk({ + ...importRange, + userIds: importClassIds, + autoMatch: true, + userId: req.user.id, + }); + if (!importResult.success || importResult.errors.length > 0) { + errors.push(...importResult.errors); + continue; + } + await this.service.createLessonAttendanceFromDingTalk(schedule.id, dto.date, req.user.id); + refreshed += 1; + imported += importResult.imported; + matched += importResult.matched; + } catch (error: unknown) { + errors.push((error as { message?: string })?.message || `排课 ${schedule.id} 刷新失败`); + } + } + + await this.logService.log({ + userId: req.user.id, + username: req.user.username, + module: '考勤管理', + action: '刷新钉钉考勤', + targetType: 'attendanceRecord', + detail: `日期${dto.date},排课${schedules.length}节,刷新${refreshed}节,钉钉新增${imported}条,匹配${matched}条${errors.length ? `,错误${errors.length}条` : ''}`, + status: errors.length > 0 && refreshed === 0 ? 'failure' : 'success', + }); + + if (schedules.length === 0) { + return { refreshed, imported, matched, errors: ['当前条件下没有可刷新的课程'] }; + } + return { refreshed, imported, matched, errors }; + } + // ── Batch create attendance records ── @Post('attendance-records/batch') @RequirePermission('attendance:create') @@ -277,6 +401,16 @@ export class AttendanceController { res.end(); } + @Get('attendance-records/schedules') + @RequirePermission('attendance:view') + async getAttendanceScheduleOptions( + @Query() query: AttendanceScheduleOptionsQueryDto, + @Request() req: { user: RequestUser }, + ) { + await this.assertClassAccess(req, query.classId); + return this.service.getScheduleOptionsForAttendance(query.classId, query.date); + } + // ── List attendance records with filters ── @Get('attendance-records') @RequirePermission('attendance:view') @@ -522,6 +656,7 @@ export class AttendanceController { req.user.id, dto.classId, canManageAll, + dto.start, ); } diff --git a/apps/server/src/attendance/attendance.module.ts b/apps/server/src/attendance/attendance.module.ts index 8a341b0..01d0e29 100644 --- a/apps/server/src/attendance/attendance.module.ts +++ b/apps/server/src/attendance/attendance.module.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { AttendanceRecord, AttendanceSession, AttendanceDevice, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping } from '../entities'; +import { AttendanceRecord, AttendanceSession, AttendanceDevice, AttendancePeriodConfig, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping } from '../entities'; import { AttendanceService } from './attendance.service'; import { AttendanceImportService } from './attendance-import.service'; import { AttendanceSettlementService } from './attendance-settlement.service'; @@ -10,7 +10,7 @@ import { IntegrationModule } from '../integration/integration.module'; @Module({ imports: [ - TypeOrmModule.forFeature([AttendanceRecord, AttendanceSession, AttendanceDevice, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping]), + TypeOrmModule.forFeature([AttendanceRecord, AttendanceSession, AttendanceDevice, AttendancePeriodConfig, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping]), OperationLogsModule, IntegrationModule, ], diff --git a/apps/server/src/attendance/attendance.service.ts b/apps/server/src/attendance/attendance.service.ts index f0c24bc..a689aa7 100644 --- a/apps/server/src/attendance/attendance.service.ts +++ b/apps/server/src/attendance/attendance.service.ts @@ -5,6 +5,7 @@ import { AttendanceRecord, AttendanceSession, AttendanceDevice, + AttendancePeriodConfig, DingAttendanceRaw, Class, Student, @@ -24,6 +25,7 @@ import { UpdateAttendanceRecordDto, GenerateAttendanceFromSchedulesDto, GenerateFromSchedulesDto, + SaveAttendancePeriodConfigsDto, } from './dto/attendance.dto'; /** Keyed mutex serializing operations on the same attendance session. */ @@ -69,11 +71,20 @@ export class AttendanceService { private attendanceSessionRepo: Repository, @InjectRepository(AttendanceDevice) private attendanceDeviceRepo: Repository, + @InjectRepository(AttendancePeriodConfig) + private attendancePeriodConfigRepo: Repository, private dataSource: DataSource, ) {} private sessionMutex = new SessionMutex(); + private readonly defaultAttendancePeriods = [ + { periodKey: 'morning_reading', label: '早自习', startTime: '07:30', endTime: '08:30', sortOrder: 1 }, + { periodKey: 'morning', label: '早课', startTime: '09:00', endTime: '12:00', sortOrder: 2 }, + { periodKey: 'afternoon', label: '晚课', startTime: '14:00', endTime: '17:00', sortOrder: 3 }, + { periodKey: 'evening_study', label: '晚自习', startTime: '18:30', endTime: '21:00', sortOrder: 4 }, + ] as const; + private formatDeviceDetail(device: AttendanceDevice): string { const classroomName = device.classroom?.name; return classroomName ? `${device.deviceName} · ${classroomName}` : device.deviceName; @@ -144,6 +155,28 @@ export class AttendanceService { if (!assignment) throw new BadRequestException('只能访问自己任教班级的考勤'); } + private isClassStudentActiveOnDate(classStudent: Pick, lessonDate: string): boolean { + const status = classStudent.status ?? 'active'; + if (!['active', 'left'].includes(status)) return false; + if (classStudent.joinDate && classStudent.joinDate > lessonDate) return false; + if (classStudent.leaveDate && classStudent.leaveDate < lessonDate) return false; + return true; + } + + private async getClassStudentsForLesson( + classId: number, + lessonDate: string, + relations: string[] = [], + ): Promise { + const classStudents = await this.classStudentRepo.find({ + where: { classId, status: In(['active', 'left']) }, + relations, + }); + return classStudents.filter((classStudent) => + this.isClassStudentActiveOnDate(classStudent, lessonDate), + ); + } + /** List classes the current user may select for DingTalk attendance import. */ async getImportableClasses(userId: number, isSuperAdmin = false) { if (isSuperAdmin) { @@ -174,6 +207,7 @@ export class AttendanceService { userId: number, classId: number, isSuperAdmin = false, + lessonDate?: string, ): Promise { if (!isSuperAdmin) { const assignment = await this.classTeacherRepo.findOne({ @@ -187,9 +221,11 @@ export class AttendanceService { if (!cls) throw new NotFoundException(`Class ${classId} not found`); } - const classStudents = await this.classStudentRepo.find({ - where: { classId, status: 'active' }, - }); + const classStudents = lessonDate + ? await this.getClassStudentsForLesson(classId, lessonDate) + : await this.classStudentRepo.find({ + where: { classId, status: 'active' }, + }); const studentIds = [...new Set(classStudents.map((item) => item.studentId))]; if (studentIds.length === 0) { throw new BadRequestException('该班级暂无在读学生'); @@ -375,15 +411,17 @@ export class AttendanceService { where: { attendanceSessionId: existing.id }, order: { studentId: 'ASC' }, }); - const classStudents = await this.classStudentRepo.find({ - where: { classId: schedule.classId!, status: 'active' }, - relations: ['student'], - }); + const classStudents = await this.getClassStudentsForLesson( + schedule.classId!, + lessonDate, + ['student'], + ); const studentsById = new Map( classStudents.map((classStudent) => [classStudent.studentId, classStudent.student]), ); const existingStudentIds = new Set(existingRecords.map((record) => record.studentId)); + const lessonSessionKey = this.mapLessonScheduleTimeToSession(schedule.startTime); const updatedRecords = existingRecords.map((record) => { record.student = studentsById.get(record.studentId)!; // Preserve manual corrections only while the lesson is still in progress. @@ -422,7 +460,7 @@ export class AttendanceService { scheduleId, attendanceSessionId: existing.id, attendanceDate: lessonDate, - session: this.mapScheduleTimeToSession(schedule.startTime), + session: lessonSessionKey, status: this.mapDingTalkStatus(raw, finalize), source: 'dingtalk', ...this.getLessonPunchMetadata( @@ -456,10 +494,11 @@ export class AttendanceService { const recordRepo = manager.getRepository(AttendanceRecord); const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, schedule, lessonDate); - const classStudents = await this.classStudentRepo.find({ - where: { classId: schedule.classId!, status: 'active' }, - relations: ['student'], - }); + const classStudents = await this.getClassStudentsForLesson( + schedule.classId!, + lessonDate, + ['student'], + ); if (classStudents.length === 0) throw new BadRequestException('该班级暂无在读学生'); let session: AttendanceSession; @@ -495,6 +534,7 @@ export class AttendanceService { throw err; } + const lessonSessionKey = this.mapLessonScheduleTimeToSession(schedule.startTime); const records = classStudents.map((classStudent) => { const raw = this.selectDingTalkRecordsForLesson( rawByStudent.get(classStudent.studentId) ?? [], @@ -508,7 +548,7 @@ export class AttendanceService { scheduleId, attendanceSessionId: session.id, attendanceDate: lessonDate, - session: this.mapScheduleTimeToSession(schedule.startTime), + session: lessonSessionKey, status: this.mapDingTalkStatus(raw, finalize), source: 'dingtalk', ...this.getLessonPunchMetadata( @@ -539,9 +579,7 @@ export class AttendanceService { schedule: Pick, lessonDate: string, ): Promise> { - const classStudents = await this.classStudentRepo.find({ - where: { classId, status: 'active' }, - }); + const classStudents = await this.getClassStudentsForLesson(classId, lessonDate); if (classStudents.length === 0) return new Map(); const studentIds = classStudents.map((cs) => cs.studentId); const window = this.getLessonAttendanceWindow(schedule, lessonDate); @@ -654,7 +692,7 @@ export class AttendanceService { }); const classStudents = await this.classStudentRepo.find({ - where: { classId, status: 'active' }, + where: { classId, status: In(['active', 'left']) }, relations: ['student'], }); @@ -679,8 +717,11 @@ export class AttendanceService { if (sched.weekDay !== weekDay) continue; if (dateStr < sched.startDate || dateStr > sched.endDate) continue; - const session = this.mapScheduleTimeToSession(sched.startTime); - for (const cs of classStudents) { + const session = await this.mapScheduleTimeToSession(sched.startTime); + const classStudentsForDate = classStudents.filter((cs) => + this.isClassStudentActiveOnDate(cs, dateStr), + ); + for (const cs of classStudentsForDate) { const key = `${cs.studentId}|${dateStr}|${session}`; if (existingKeys.has(key)) continue; @@ -754,7 +795,97 @@ export class AttendanceService { return shifted.toISOString().slice(0, 10); } - private mapScheduleTimeToSession(startTime: string): string { + private async ensureAttendancePeriodConfigs() { + const count = await this.attendancePeriodConfigRepo.count(); + if (count === 0) { + await this.attendancePeriodConfigRepo.save( + this.defaultAttendancePeriods.map((period) => this.attendancePeriodConfigRepo.create({ + ...period, + enabled: true, + })), + ); + } + return this.attendancePeriodConfigRepo.find({ order: { sortOrder: 'ASC', id: 'ASC' } }); + } + + async getAttendancePeriodConfigs() { + return this.ensureAttendancePeriodConfigs(); + } + + async getRefreshableSchedules(date: string, classId?: number, session?: string, accessibleClassIds?: number[]) { + const parsedDate = new Date(`${date}T00:00:00`); + if (Number.isNaN(parsedDate.getTime())) throw new BadRequestException('无效日期'); + const weekDay = parsedDate.getDay() === 0 ? 7 : parsedDate.getDay(); + const qb = this.scheduleRepo + .createQueryBuilder('schedule') + .where('schedule.scheduleType = :scheduleType', { scheduleType: ScheduleType.INTERNAL }) + .andWhere('schedule.status = :status', { status: 'active' }) + .andWhere('schedule.classId IS NOT NULL') + .andWhere('schedule.weekDay = :weekDay', { weekDay }) + .andWhere('schedule.startDate <= :date', { date }) + .andWhere('schedule.endDate >= :date', { date }); + + if (classId) { + qb.andWhere('schedule.classId = :classId', { classId }); + } else if (accessibleClassIds) { + if (accessibleClassIds.length === 0) return []; + qb.andWhere('schedule.classId IN (:...accessibleClassIds)', { accessibleClassIds }); + } + + const schedules = await qb.orderBy('schedule.startTime', 'ASC').getMany(); + if (!session) return schedules; + + const matchedSchedules: ClassSchedule[] = []; + for (const schedule of schedules) { + if ((await this.mapScheduleTimeToSession(schedule.startTime)) === session) { + matchedSchedules.push(schedule); + } + } + return matchedSchedules; + } + + async saveAttendancePeriodConfigs(dto: SaveAttendancePeriodConfigsDto) { + const seen = new Set(); + const normalized = dto.periods.map((period, index) => { + const periodKey = period.periodKey.trim(); + const label = period.label.trim(); + if (!periodKey || !label) throw new BadRequestException('时段标识和名称不能为空'); + if (seen.has(periodKey)) throw new BadRequestException(`时段标识 ${periodKey} 重复`); + seen.add(periodKey); + if (this.toMinutes(period.endTime) <= this.toMinutes(period.startTime)) { + throw new BadRequestException(`${label} 的结束时间必须晚于开始时间`); + } + return { + periodKey, + label, + startTime: period.startTime, + endTime: period.endTime, + sortOrder: period.sortOrder ?? index + 1, + enabled: period.enabled ?? true, + }; + }).sort((left, right) => left.sortOrder - right.sortOrder); + + for (let index = 1; index < normalized.length; index += 1) { + const previous = normalized[index - 1]; + const current = normalized[index]; + if (previous.enabled && current.enabled && this.toMinutes(current.startTime) < this.toMinutes(previous.endTime)) { + throw new BadRequestException(`${previous.label} 和 ${current.label} 时间段不能重叠`); + } + } + + await this.attendancePeriodConfigRepo.clear(); + await this.attendancePeriodConfigRepo.save( + normalized.map((period) => this.attendancePeriodConfigRepo.create(period)), + ); + return this.getAttendancePeriodConfigs(); + } + + async resetAttendancePeriodConfigs() { + await this.attendancePeriodConfigRepo.clear(); + return this.ensureAttendancePeriodConfigs(); + } + + private mapLessonScheduleTimeToSession(startTime: string): string { const hour = parseInt(startTime.slice(0, 2), 10); if (hour < 8) return 'morning_reading'; if (hour < 12) return 'morning'; @@ -763,15 +894,31 @@ export class AttendanceService { return 'night_check'; } + private async mapScheduleTimeToSession(startTime: string): Promise { + const startMinutes = this.toMinutes(startTime); + const periods = (await this.ensureAttendancePeriodConfigs()).filter((period) => period.enabled); + const matched = periods.find((period) => { + const periodStart = this.toMinutes(period.startTime); + const periodEnd = this.toMinutes(period.endTime); + return startMinutes >= periodStart && startMinutes < periodEnd; + }); + if (matched) return matched.periodKey; + throw new BadRequestException(`课程开始时间 ${startTime} 未匹配到考勤时段,请先配置考勤时段`); + } + // ── Attendance summary ── async getSummary(query: AttendanceSummaryQueryDto, accessibleClassIds?: number[]) { const qb = this.attendanceRepo.createQueryBuilder('ar'); if (query.classId) { qb.andWhere('ar.classId = :classId', { classId: query.classId }); - } else if (accessibleClassIds) { + } + if (query.scheduleId) { + qb.andWhere('ar.scheduleId = :scheduleId', { scheduleId: query.scheduleId }); + } + if (!query.classId && accessibleClassIds) { if (accessibleClassIds.length === 0) - return { total: 0, present: 0, late: 0, absent: 0, leave: 0, presentRate: 0 }; + return { total: 0, present: 0, late: 0, absent: 0, leave: 0, pending: 0, presentRate: 0 }; qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds }); } if (query.dateFrom) { @@ -780,6 +927,9 @@ export class AttendanceService { if (query.dateTo) { qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo }); } + if (query.session) { + qb.andWhere('ar.session = :session', { session: query.session }); + } const rows = await qb.getMany(); @@ -788,9 +938,10 @@ export class AttendanceService { const late = rows.filter((r) => r.status === 'late').length; const absent = rows.filter((r) => r.status === 'absent').length; const leave = rows.filter((r) => r.status === 'leave').length; + const pending = rows.filter((r) => r.status === 'pending').length; const presentRate = total > 0 ? Number(((present / total) * 100).toFixed(1)) : 0; - return { total, present, late, absent, leave, presentRate }; + return { total, present, late, absent, leave, pending, presentRate }; } // ── Attendance calendar ── @@ -812,6 +963,25 @@ export class AttendanceService { return this.buildCalendar(classId, weekStart); } + private getWeekDayForDate(date: string): number { + const day = new Date(`${date}T00:00:00+08:00`).getUTCDay(); + return day === 0 ? 7 : day; + } + + async getScheduleOptionsForAttendance(classId: number, date: string) { + const weekDay = this.getWeekDayForDate(date); + return this.scheduleRepo + .createQueryBuilder('cs') + .where('cs.classId = :classId', { classId }) + .andWhere('cs.weekDay = :weekDay', { weekDay }) + .andWhere('cs.startDate <= :date', { date }) + .andWhere('cs.endDate >= :date', { date }) + .andWhere('cs.status = :status', { status: 'active' }) + .orderBy('cs.startTime', 'ASC') + .addOrderBy('cs.subject', 'ASC') + .getMany(); + } + private async buildCalendar(classId: number, weekStart: string) { // Compute weekEnd (Sunday = weekStart + 6 days) const start = new Date(weekStart); @@ -1018,6 +1188,7 @@ export class AttendanceService { async findAllForExport( query: { classId?: number; + scheduleId?: number; dateFrom?: string; dateTo?: string; session?: string; @@ -1029,6 +1200,9 @@ export class AttendanceService { const qb = this.attendanceRepo.createQueryBuilder('ar'); qb.leftJoinAndSelect('ar.student', 'student').leftJoinAndSelect('ar.class', 'class'); + if (query.scheduleId) { + qb.andWhere('ar.scheduleId = :scheduleId', { scheduleId: query.scheduleId }); + } if (query.classId) { qb.andWhere('ar.classId = :classId', { classId: query.classId }); } else if (accessibleClassIds) { diff --git a/apps/server/src/attendance/dto/attendance.dto.ts b/apps/server/src/attendance/dto/attendance.dto.ts index 22a01c8..c07dce0 100644 --- a/apps/server/src/attendance/dto/attendance.dto.ts +++ b/apps/server/src/attendance/dto/attendance.dto.ts @@ -3,16 +3,53 @@ import { IsOptional, IsString, IsInt, + IsBoolean, IsDateString, IsIn, ValidateNested, IsNotEmpty, ArrayNotEmpty, + Matches, Max, Min, } from 'class-validator'; import { Type } from 'class-transformer'; + +export class AttendancePeriodConfigItemDto { + @IsString() + @IsNotEmpty() + periodKey: string; + + @IsString() + @IsNotEmpty() + label: string; + + @IsString() + @Matches(/^([01]\d|2[0-3]):[0-5]\d$/) + startTime: string; + + @IsString() + @Matches(/^([01]\d|2[0-3]):[0-5]\d$/) + endTime: string; + + @IsOptional() + @IsInt() + sortOrder?: number; + + @IsOptional() + @IsBoolean() + enabled?: boolean; +} + +export class SaveAttendancePeriodConfigsDto { + @IsArray() + @ArrayNotEmpty() + @ValidateNested({ each: true }) + @Type(() => AttendancePeriodConfigItemDto) + periods: AttendancePeriodConfigItemDto[]; +} + export class AttendanceRecordItem { @IsInt() @IsNotEmpty() @@ -27,7 +64,6 @@ export class AttendanceRecordItem { attendanceDate: string; @IsString() -@IsIn(['morning_reading', 'morning', 'afternoon', 'evening_study', 'night_check']) @IsNotEmpty() session: string; @@ -59,6 +95,11 @@ export class AttendanceSummaryQueryDto { @Type(() => Number) classId?: number; + @IsOptional() + @IsInt() + @Type(() => Number) + scheduleId?: number; + @IsOptional() @IsDateString() dateFrom?: string; @@ -66,6 +107,26 @@ export class AttendanceSummaryQueryDto { @IsOptional() @IsDateString() dateTo?: string; + + @IsOptional() + @IsString() + session?: string; +} + + +export class RefreshDingTalkAttendanceDto { + @IsDateString() + @IsNotEmpty() + date: string; + + @IsOptional() + @IsInt() + @Type(() => Number) + classId?: number; + + @IsOptional() + @IsString() + session?: string; } export class AttendanceCalendarQueryDto { @@ -112,6 +173,17 @@ export class QueryDingRawDto { pageSize?: number; } +export class AttendanceScheduleOptionsQueryDto { + @IsInt() + @Type(() => Number) + @IsNotEmpty() + classId: number; + + @IsDateString() + @IsNotEmpty() + date: string; +} + export class QueryAttendanceRecordsDto { @IsOptional() @IsInt() @@ -137,7 +209,7 @@ export class QueryAttendanceRecordsDto { @IsOptional() @IsString() - @IsIn(['present', 'late', 'absent', 'leave']) + @IsIn(['present', 'late', 'absent', 'leave', 'pending']) status?: string; @IsOptional() diff --git a/apps/server/src/entities/attendance-period-config.entity.ts b/apps/server/src/entities/attendance-period-config.entity.ts new file mode 100644 index 0000000..57e58d5 --- /dev/null +++ b/apps/server/src/entities/attendance-period-config.entity.ts @@ -0,0 +1,33 @@ +import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; + +@Entity('attendance_period_configs') +@Index(['periodKey'], { unique: true }) +@Index(['sortOrder']) +export class AttendancePeriodConfig { + @PrimaryGeneratedColumn() + id: number; + + @Column({ name: 'period_key', type: 'varchar', length: 40 }) + periodKey: string; + + @Column({ type: 'varchar', length: 40 }) + label: string; + + @Column({ name: 'start_time', type: 'varchar', length: 5 }) + startTime: string; + + @Column({ name: 'end_time', type: 'varchar', length: 5 }) + endTime: string; + + @Column({ name: 'sort_order', type: 'integer', default: 0 }) + sortOrder: number; + + @Column({ type: 'boolean', default: true }) + enabled: boolean; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/apps/server/src/entities/index.ts b/apps/server/src/entities/index.ts index b0e6d31..8b5cba5 100644 --- a/apps/server/src/entities/index.ts +++ b/apps/server/src/entities/index.ts @@ -23,6 +23,7 @@ export { ClassSchedule, ScheduleType } from './class-schedule.entity'; export { AttendanceRecord } from './attendance-record.entity'; export { AttendanceSession } from './attendance-session.entity'; export { AttendanceDevice, AttendanceDeviceStatus } from './attendance-device.entity'; +export { AttendancePeriodConfig } from './attendance-period-config.entity'; export { DingAttendanceRaw } from './ding-attendance-raw.entity'; export { SyncLog } from './sync-log.entity'; export { SyncState } from './sync-state.entity'; diff --git a/apps/server/src/operation-logs/operation-logs.service.ts b/apps/server/src/operation-logs/operation-logs.service.ts index f7b005d..644e554 100644 --- a/apps/server/src/operation-logs/operation-logs.service.ts +++ b/apps/server/src/operation-logs/operation-logs.service.ts @@ -23,6 +23,17 @@ export class OperationLogsService { return this.repo.save(entry); } + async findLatestDingTalkAttendancePull() { + return this.repo + .createQueryBuilder('log') + .where('log.module = :module', { module: '考勤管理' }) + .andWhere('log.action IN (:...actions)', { + actions: ['拉取钉钉课程考勤', '查看已拉取课程考勤', '钉钉考勤导入', '刷新钉钉考勤'], + }) + .orderBy('log.createdAt', 'DESC') + .getOne(); + } + async findAll(query?: { module?: string; userId?: number; diff --git a/apps/server/src/rbac/rbac.service.ts b/apps/server/src/rbac/rbac.service.ts index 6187a8e..a76dd6a 100644 --- a/apps/server/src/rbac/rbac.service.ts +++ b/apps/server/src/rbac/rbac.service.ts @@ -143,6 +143,26 @@ const DEPRECATED_PERMISSION_CODES = [ const DEPRECATED_PERMISSION_CODE_SET = new Set(DEPRECATED_PERMISSION_CODES); +function getChinaDateParts(date = new Date()): { date: string; weekDay: number } { + const parts = Object.fromEntries( + new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + weekday: 'short', + }) + .formatToParts(date) + .filter((part) => part.type !== 'literal') + .map((part) => [part.type, part.value]), + ); + const weekDays: Record = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7 }; + return { + date: `${parts.year}-${parts.month}-${parts.day}`, + weekDay: weekDays[parts.weekday], + }; +} + export const PRESET_ROLES: Array<{ name: string; code: string; @@ -684,11 +704,8 @@ export class RbacService { subject: t.subject, })); - // Get today's day of week (1=Monday, 7=Sunday) - const today = new Date(); - const weekDay = today.getDay(); // 0=Sun → convert to 1-7 - const adjustedWeekDay = weekDay === 0 ? 7 : weekDay; - const todayStr = today.toISOString().slice(0, 10); + // Get today's China business date and day of week (1=Monday, 7=Sunday) + const { date: todayStr, weekDay: adjustedWeekDay } = getChinaDateParts(); // Get today's schedules for assigned classes const todaySchedules = await this.classScheduleRepo diff --git a/apps/server/src/students/dto/student.dto.ts b/apps/server/src/students/dto/student.dto.ts index d6d1904..276e1c8 100644 --- a/apps/server/src/students/dto/student.dto.ts +++ b/apps/server/src/students/dto/student.dto.ts @@ -110,4 +110,14 @@ export class QueryStudentDto { @Type(() => Number) @IsInt() organizationId?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + classId?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + teacherId?: number; } diff --git a/apps/server/src/students/students.controller.ts b/apps/server/src/students/students.controller.ts index 9837234..e45cb38 100644 --- a/apps/server/src/students/students.controller.ts +++ b/apps/server/src/students/students.controller.ts @@ -175,6 +175,16 @@ export class StudentsController { return this.service.getBasicLookups(); } + @Get('filter-lookups') + @RequirePermission('student:view') + async getFilterLookups(@Request() req: AuthenticatedRequest) { + const classIds = await this.service.getAccessibleClassIds( + req.user.id, + this.canManageAllStudents(req), + ); + return this.service.getFilterLookups(classIds); + } + @Get() @RequirePermission('student:view') async findAll( @@ -194,7 +204,7 @@ export class StudentsController { @Get('export') @RequirePermission('student:export') async exportExcel( - @Query('includeArchived') includeArchived?: string, + @Query() query: QueryStudentDto, @Res() res?: Response, @Request() req?: any, ) { @@ -202,10 +212,7 @@ export class StudentsController { req.user.id, this.canManageAllStudents(req), ); - const students = await this.service.findAll( - { includeArchived: includeArchived === 'true' }, - classIds, - ); + const students = await this.service.findAll(query, classIds); const workbook = new ExcelJS.Workbook(); const ws = workbook.addWorksheet('学生名单'); ws.columns = STUDENT_EXPORT_COLUMNS; diff --git a/apps/server/src/students/students.service.ts b/apps/server/src/students/students.service.ts index adf7a86..f6c1211 100644 --- a/apps/server/src/students/students.service.ts +++ b/apps/server/src/students/students.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, Like, Not, In, FindOptionsWhere } from 'typeorm'; +import { Repository, Like, Not, In, FindOptionsWhere, IsNull } from 'typeorm'; import { Student } from '../entities/student.entity'; import { Class } from '../entities/class.entity'; import { ClassStudent } from '../entities/class-student.entity'; @@ -41,6 +41,8 @@ export class StudentsService { status?: string; includeArchived?: boolean; organizationId?: number | string; + classId?: number | string; + teacherId?: number | string; }, accessibleClassIds?: number[], ) { @@ -52,10 +54,28 @@ export class StudentsService { } else if (!query?.includeArchived) { where.status = Not(In(['archived', 'staff'])); } - if (accessibleClassIds) { - if (accessibleClassIds.length === 0) return []; + + let scopedClassIds = accessibleClassIds ? [...accessibleClassIds] : undefined; + if (query?.teacherId) { + const teacherAssignments = await this.classTeacherRepo.find({ + where: { userId: Number(query.teacherId) }, + }); + const teacherClassIds = [...new Set(teacherAssignments.map((item) => item.classId))]; + scopedClassIds = scopedClassIds + ? scopedClassIds.filter((classId) => teacherClassIds.includes(classId)) + : teacherClassIds; + } + if (query?.classId) { + const classId = Number(query.classId); + scopedClassIds = scopedClassIds + ? scopedClassIds.filter((accessibleClassId) => accessibleClassId === classId) + : [classId]; + } + + if (scopedClassIds) { + if (scopedClassIds.length === 0) return []; const classStudents = await this.classStudentRepo.find({ - where: { classId: In(accessibleClassIds), status: 'active' }, + where: { classId: In(scopedClassIds), status: 'active' }, }); const studentIds = [...new Set(classStudents.map((item) => item.studentId))]; if (studentIds.length === 0) return []; @@ -64,6 +84,42 @@ export class StudentsService { return this.repo.find({ where, order: { createdAt: 'DESC' }, relations: ['organization'] }); } + async getFilterLookups(accessibleClassIds?: number[]) { + if (accessibleClassIds && accessibleClassIds.length === 0) { + return { classes: [], teachers: [] }; + } + + const classWhere = accessibleClassIds + ? { id: In(accessibleClassIds), isArchived: false } + : { isArchived: false }; + const classes = await this.classRepo.find({ + select: ['id', 'name', 'code'], + where: classWhere, + order: { name: 'ASC' }, + }); + + const teacherWhere = accessibleClassIds + ? { classId: In(accessibleClassIds) } + : { classId: In(classes.map((item) => item.id)), userId: Not(IsNull()) }; + const assignments = classes.length + ? await this.classTeacherRepo.find({ where: teacherWhere, relations: ['user'] }) + : []; + const teacherMap = new Map(); + for (const assignment of assignments) { + if (!assignment.user || !assignment.user.isActive || assignment.user.isArchived) continue; + teacherMap.set(assignment.userId, { + id: assignment.userId, + name: assignment.user.name || assignment.user.username, + username: assignment.user.username, + }); + } + + return { + classes: classes.map((item) => ({ id: item.id, name: item.name, code: item.code })), + teachers: [...teacherMap.values()].sort((a, b) => a.name.localeCompare(b.name, 'zh-CN')), + }; + } + async findOne(id: number) { const student = await this.repo.findOne({ where: { id }, diff --git a/apps/server/src/wallets/wallets.controller.ts b/apps/server/src/wallets/wallets.controller.ts index 5e36b24..6bf4304 100644 --- a/apps/server/src/wallets/wallets.controller.ts +++ b/apps/server/src/wallets/wallets.controller.ts @@ -13,8 +13,18 @@ export class WalletsController { @Get() @RequirePermission('wallet:view') - findAll(@Query('keyword') keyword?: string, @Query('debtOnly') debtOnly?: string) { - return this.service.findAll({ keyword, debtOnly: debtOnly === 'true' }); + findAll( + @Query('keyword') keyword?: string, + @Query('debtOnly') debtOnly?: string, + @Query('roomType') roomType?: string, + ) { + return this.service.findAll({ keyword, debtOnly: debtOnly === 'true', roomType }); + } + + @Get('room-types') + @RequirePermission('wallet:view') + findRoomTypes() { + return this.service.findRoomTypes(); } @Get('transactions') diff --git a/apps/server/src/wallets/wallets.module.ts b/apps/server/src/wallets/wallets.module.ts index dd424d3..c5e558a 100644 --- a/apps/server/src/wallets/wallets.module.ts +++ b/apps/server/src/wallets/wallets.module.ts @@ -4,12 +4,14 @@ import { Bill } from '../entities/bill.entity'; import { Student } from '../entities/student.entity'; import { StudentWallet } from '../entities/student-wallet.entity'; import { WalletTransaction } from '../entities/wallet-transaction.entity'; +import { Room } from '../entities/room.entity'; +import { Occupancy } from '../entities/occupancy.entity'; import { OperationLogsModule } from '../operation-logs/operation-logs.module'; import { WalletsController } from './wallets.controller'; import { WalletsService } from './wallets.service'; @Module({ - imports: [TypeOrmModule.forFeature([StudentWallet, WalletTransaction, Student, Bill]), OperationLogsModule], + imports: [TypeOrmModule.forFeature([StudentWallet, WalletTransaction, Student, Bill, Room, Occupancy]), OperationLogsModule], controllers: [WalletsController], providers: [WalletsService], exports: [WalletsService], diff --git a/apps/server/src/wallets/wallets.service.ts b/apps/server/src/wallets/wallets.service.ts index 1523fb8..1078446 100644 --- a/apps/server/src/wallets/wallets.service.ts +++ b/apps/server/src/wallets/wallets.service.ts @@ -8,6 +8,7 @@ import { WalletTransaction } from '../entities/wallet-transaction.entity'; import { In } from 'typeorm'; import { BatchChangeWalletBalanceDto, ChangeWalletBalanceDto } from './dto/wallet.dto'; import { FinancialOperationsService } from '../financial-operations/financial-operations.service'; +import { Room } from '../entities/room.entity'; const money = (value: number | string | null | undefined) => Number(Number(value || 0).toFixed(2)); @@ -21,21 +22,41 @@ export class WalletsService { private financialOperations?: FinancialOperationsService, ) {} - async findAll(query?: { keyword?: string; debtOnly?: boolean }) { - const students = await this.studentRepo + async findAll(query?: { keyword?: string; debtOnly?: boolean; roomType?: string }) { + const qb = this.studentRepo .createQueryBuilder('student') - .where('student.status = :status', { status: 'active' }) - .andWhere( - query?.keyword - ? '(student.name LIKE :keyword OR student.studentNo LIKE :keyword)' - : '1 = 1', - query?.keyword ? { keyword: `%${query.keyword}%` } : {}, - ) - .orderBy('student.name', 'ASC') - .getMany(); - if (!students.length) return []; + .leftJoin('student.occupancies', 'occupancy', 'occupancy.checkOutDate IS NULL') + .leftJoin('occupancy.room', 'room') + .where('student.status = :status', { status: 'active' }); - const ids = students.map((student) => student.id); + if (query?.keyword) { + qb.andWhere('(student.name LIKE :keyword OR student.studentNo LIKE :keyword)', { + keyword: `%${query.keyword}%`, + }); + } + if (query?.roomType) { + qb.andWhere('room.roomType = :roomType', { roomType: query.roomType }); + } + + const rows = await qb + .select([ + 'student.id AS studentId', + 'student.name AS studentName', + 'student.studentNo AS studentNo', + 'room.roomType AS roomType', + 'room.roomNumber AS roomNumber', + ]) + .orderBy('student.name', 'ASC') + .getRawMany<{ + studentId: number; + studentName: string; + studentNo: string | null; + roomType: string | null; + roomNumber: string | null; + }>(); + if (!rows.length) return []; + + const ids = rows.map((row) => Number(row.studentId)); const wallets = await this.walletRepo.find({ where: { studentId: In(ids) } }); const bills = await this.dataSource.getRepository(Bill) .createQueryBuilder('bill') @@ -47,17 +68,34 @@ export class WalletsService { .getRawMany<{ studentId: number; outstandingAmount: string }>(); const walletMap = new Map(wallets.map((wallet) => [wallet.studentId, wallet])); const debtMap = new Map(bills.map((bill) => [Number(bill.studentId), money(bill.outstandingAmount)])); - return students - .map((student) => ({ - studentId: student.id, - studentName: student.name, - studentNo: student.studentNo, - balance: money(walletMap.get(student.id)?.balance), - outstandingAmount: debtMap.get(student.id) || 0, + return rows + .map((row) => ({ + studentId: Number(row.studentId), + studentName: row.studentName, + studentNo: row.studentNo || undefined, + roomType: row.roomType || undefined, + roomNumber: row.roomNumber || undefined, + balance: money(walletMap.get(Number(row.studentId))?.balance), + outstandingAmount: debtMap.get(Number(row.studentId)) || 0, })) .filter((row) => !query?.debtOnly || row.outstandingAmount > 0); } + async findRoomTypes() { + const rows = await this.dataSource + .getRepository(Room) + .createQueryBuilder('room') + .innerJoin('room.occupancies', 'occupancy', 'occupancy.checkOutDate IS NULL') + .innerJoin('occupancy.student', 'student', 'student.status = :status', { status: 'active' }) + .select('room.roomType', 'roomType') + .where('room.roomType IS NOT NULL') + .andWhere("room.roomType <> ''") + .distinct(true) + .orderBy('room.roomType', 'ASC') + .getRawMany<{ roomType: string }>(); + return rows.map((row) => row.roomType); + } + async findTransactions(studentId: number) { return this.transactionRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }); } diff --git a/docker/mysql/init.sql b/docker/mysql/init.sql index 7829448..506290f 100644 --- a/docker/mysql/init.sql +++ b/docker/mysql/init.sql @@ -18,6 +18,7 @@ DROP TABLE IF EXISTS `student_enrollments`; DROP TABLE IF EXISTS `student_profiles`; DROP TABLE IF EXISTS `notifications`; DROP TABLE IF EXISTS `ding_attendance_raw`; +DROP TABLE IF EXISTS `attendance_period_configs`; DROP TABLE IF EXISTS `attendance_devices`; DROP TABLE IF EXISTS `attendance_records`; DROP TABLE IF EXISTS `attendance_sessions`; @@ -29,6 +30,7 @@ DROP TABLE IF EXISTS `classroom_rentals`; DROP TABLE IF EXISTS `classrooms`; DROP TABLE IF EXISTS `deposit_installments`; DROP TABLE IF EXISTS `deposits`; +DROP TABLE IF EXISTS `financial_operations`; DROP TABLE IF EXISTS `operation_logs`; DROP TABLE IF EXISTS `student_wallets`; DROP TABLE IF EXISTS `wallet_transactions`; @@ -69,10 +71,12 @@ CREATE TABLE IF NOT EXISTS `permissions` (`id` int NOT NULL AUTO_INCREMENT, `cod CREATE TABLE IF NOT EXISTS `roles` (`id` int NOT NULL AUTO_INCREMENT, `name` varchar(30) NOT NULL, `code` varchar(30) NULL, `description` varchar(200) NULL, `is_system` tinyint NOT NULL DEFAULT 0, `status` tinyint NOT NULL DEFAULT 1, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX `IDX_648e3f5447f725579d7d4ffdfb` (`name`), UNIQUE INDEX `IDX_f6d54f95c31b73fb1bdd8e91d0` (`code`), PRIMARY KEY (`id`)) ENGINE=InnoDB; CREATE TABLE IF NOT EXISTS `users` (`id` int NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL, `password_hash` varchar(255) NOT NULL, `name` varchar(50) NULL, `is_active` tinyint NOT NULL DEFAULT 1, `last_login_at` datetime NULL, `is_archived` tinyint NOT NULL DEFAULT 0, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), `profile` text NULL, UNIQUE INDEX `IDX_fe0bb3f6520ee0469504521e71` (`username`), PRIMARY KEY (`id`)) ENGINE=InnoDB; CREATE TABLE IF NOT EXISTS `students` (`id` int NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL, `student_no` varchar(30) NULL, `phone` varchar(20) NULL, `id_number` varchar(30) NULL, `gender` varchar(10) NULL, `ethnicity` varchar(20) NULL, `emergency_contact` varchar(50) NULL, `emergency_phone` varchar(20) NULL, `status` varchar(20) NOT NULL DEFAULT 'active', `supervisor` varchar(50) NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), `user_id` int NULL, `organization_id` int NULL, UNIQUE INDEX `IDX_fb3eff90b11bddf7285f9b4e28` (`user_id`), UNIQUE INDEX `REL_fb3eff90b11bddf7285f9b4e28` (`user_id`), PRIMARY KEY (`id`)) ENGINE=InnoDB; +CREATE TABLE IF NOT EXISTS `financial_operations` (`id` int NOT NULL AUTO_INCREMENT, `operation_id` varchar(64) NOT NULL, `type` varchar(64) NOT NULL, `status` varchar(20) NOT NULL DEFAULT 'running', `result_json` text NULL, `error_message` varchar(500) NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX `IDX_financial_operations_operation_id` (`operation_id`), PRIMARY KEY (`id`)) ENGINE=InnoDB; CREATE TABLE IF NOT EXISTS `operation_logs` (`id` int NOT NULL AUTO_INCREMENT, `user_id` int NULL, `username` varchar(50) NULL, `module` varchar(50) NOT NULL, `action` varchar(50) NOT NULL, `target_id` int NULL, `target_type` varchar(50) NULL, `detail` text NULL, `ip_address` varchar(50) NULL, `user_agent` varchar(500) NULL, `status` varchar(20) NULL DEFAULT 'success', `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`)) ENGINE=InnoDB; CREATE TABLE IF NOT EXISTS `deposit_installments` (`id` int NOT NULL AUTO_INCREMENT, `deposit_id` int NOT NULL, `amount` decimal(10,2) NOT NULL, `due_date` date NOT NULL, `paid_date` date NULL, `status` varchar(20) NOT NULL DEFAULT 'pending', `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`)) ENGINE=InnoDB; CREATE TABLE IF NOT EXISTS `deposits` (`id` int NOT NULL AUTO_INCREMENT, `student_id` int NOT NULL, `amount` decimal(10,2) NOT NULL DEFAULT '500.00', `status` varchar(20) NOT NULL DEFAULT 'paid', `paid_date` date NOT NULL, `refund_date` date NULL, `refund_amount` decimal(10,2) NULL, `deduction_amount` decimal(10,2) NOT NULL DEFAULT 0.00, `deduction_reason` text NULL, `notes` text NULL, `recorded_by` int NULL, `refunded_by` int NULL, `refunded_at` datetime NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`)) ENGINE=InnoDB; CREATE TABLE IF NOT EXISTS `classrooms` (`id` int NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL, `building` varchar(50) NULL, `floor` int NULL, `capacity` int NOT NULL DEFAULT '30', `room_type` varchar(20) NOT NULL DEFAULT '大', `status` varchar(20) NOT NULL DEFAULT 'available', `notes` text NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`)) ENGINE=InnoDB; +CREATE TABLE IF NOT EXISTS `attendance_period_configs` (`id` int NOT NULL AUTO_INCREMENT, `period_key` varchar(40) NOT NULL, `label` varchar(40) NOT NULL, `start_time` varchar(5) NOT NULL, `end_time` varchar(5) NOT NULL, `sort_order` int NOT NULL DEFAULT 0, `enabled` tinyint NOT NULL DEFAULT 1, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX `IDX_attendance_period_configs_period_key` (`period_key`), INDEX `IDX_attendance_period_configs_sort_order` (`sort_order`), PRIMARY KEY (`id`)) ENGINE=InnoDB; CREATE TABLE IF NOT EXISTS `attendance_devices` (`id` int NOT NULL AUTO_INCREMENT, `device_sn` varchar(100) NOT NULL, `device_name` varchar(100) NOT NULL, `classroom_id` int NOT NULL, `status` varchar(20) NOT NULL DEFAULT 'active', `location` varchar(200) NULL, `notes` text NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX `IDX_attendance_devices_device_sn` (`device_sn`), INDEX `IDX_attendance_devices_classroom_id` (`classroom_id`), PRIMARY KEY (`id`)) ENGINE=InnoDB; CREATE TABLE IF NOT EXISTS `classroom_rentals` (`id` int NOT NULL AUTO_INCREMENT, `classroom_id` int NOT NULL, `lessor_organization_id` int NULL, `lessee_organization_id` int NULL, `start_date` date NOT NULL, `end_date` date NOT NULL, `contract_path` varchar(255) NULL, `contract_original_name` varchar(255) NULL, `daily_rate` decimal(10,2) NULL, `total_amount` decimal(10,2) NULL, `status` varchar(20) NOT NULL DEFAULT 'active', `notes` text NULL, `created_by` int NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), INDEX `IDX_b748a951d00b3f0c2090d10397` (`classroom_id`, `start_date`, `end_date`), PRIMARY KEY (`id`)) ENGINE=InnoDB; CREATE TABLE IF NOT EXISTS `classes` (`id` int NOT NULL AUTO_INCREMENT, `name` varchar(100) NOT NULL, `code` varchar(50) NOT NULL, `class_type` varchar(20) NOT NULL, `start_date` date NULL, `end_date` date NULL, `status` varchar(20) NOT NULL DEFAULT 'enrolling', `head_teacher_id` int NULL, `life_teacher_id` int NULL, `academic_teacher_id` int NULL, `max_students` int NOT NULL DEFAULT 0, `notes` text NULL, `is_archived` tinyint NOT NULL DEFAULT 0, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX `IDX_cf7491878e0fca859943862998` (`code`), PRIMARY KEY (`id`)) ENGINE=InnoDB;