feat: refine admin forms, attendance and finance workflows

Squash merge PR #23.

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

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

View File

@@ -110,4 +110,14 @@ export class QueryStudentDto {
@Type(() => Number)
@IsInt()
organizationId?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
classId?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
teacherId?: number;
}

View File

@@ -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;

View File

@@ -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<number, { id: number; name: string; username: string }>();
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 },