feat: attendance frontend page + backend list/classes endpoints

- Create AttendancePage with filter bar, table, calendar view toggle, batch add modal
- Register /attendance route with PermissionRoute (attendance:view)
- Add menu item in MainLayout with CheckCircleOutlined icon
- Add GET /attendance-records with pagination and filters
- Add GET /attendance-records/classes for class dropdown
- Add source column to AttendanceRecord entity (default: manual)
- Frontend tsc --noEmit: clean; backend: only pre-existing test errors
This commit is contained in:
2026-07-05 19:49:53 +08:00
parent b6e308ae2a
commit 41f381e941
7 changed files with 763 additions and 0 deletions

View File

@@ -13,6 +13,7 @@ import {
BatchCreateAttendanceDto,
AttendanceSummaryQueryDto,
AttendanceCalendarQueryDto,
QueryAttendanceRecordsDto,
QueryDingRawDto,
MatchDingRecordDto,
} from './dto/attendance.dto';
@@ -50,6 +51,20 @@ export class AttendanceController {
return result;
}
// ── List attendance records with filters ──
@Get('attendance-records')
@RequirePermission('attendance:view')
findAll(@Query() query: QueryAttendanceRecordsDto) {
return this.service.findAll(query);
}
// ── Get distinct classes with attendance records ──
@Get('attendance-records/classes')
@RequirePermission('attendance:view')
getClasses() {
return this.service.getClasses();
}
// ── Attendance summary ──
@Get('attendance-records/summary')
@RequirePermission('attendance:view')

View File

@@ -37,6 +37,7 @@ export class AttendanceService {
session: r.session,
status: r.status,
remark: r.remark,
source: r.source || 'manual',
}),
);
@@ -134,6 +135,64 @@ export class AttendanceService {
return Array.from(studentMap.values());
}
// ── List attendance records with filters ──
async findAll(query: {
classId?: number;
dateFrom?: string;
dateTo?: string;
session?: string;
status?: string;
source?: string;
page?: number;
pageSize?: number;
}) {
const page = query.page || 1;
const pageSize = query.pageSize || 20;
const qb = this.attendanceRepo
.createQueryBuilder('ar')
.leftJoinAndSelect('ar.student', 'student')
.leftJoinAndSelect('ar.class', 'class');
if (query.classId) {
qb.andWhere('ar.classId = :classId', { classId: query.classId });
}
if (query.dateFrom) {
qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
}
if (query.dateTo) {
qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo });
}
if (query.session) {
qb.andWhere('ar.session = :session', { session: query.session });
}
if (query.status) {
qb.andWhere('ar.status = :status', { status: query.status });
}
if (query.source) {
qb.andWhere('ar.source = :source', { source: query.source });
}
qb.orderBy('ar.attendanceDate', 'DESC').addOrderBy('ar.createdAt', 'DESC');
qb.skip((page - 1) * pageSize).take(pageSize);
const [list, total] = await qb.getManyAndCount();
return { list, total, page, pageSize };
}
// ── Get distinct classes with attendance records ──
async getClasses() {
const qb = this.attendanceRepo
.createQueryBuilder('ar')
.leftJoin('ar.class', 'class')
.select('DISTINCT ar.classId', 'classId')
.addSelect('class.name', 'className')
.where('ar.classId IS NOT NULL')
.orderBy('ar.classId', 'ASC');
return qb.getRawMany();
}
// ── DingAttendance raw records ──
async getDingRaw(query: QueryDingRawDto) {
const where: any = {};

View File

@@ -36,6 +36,10 @@ export class AttendanceRecordItem {
@IsOptional()
@IsString()
remark?: string;
@IsOptional()
@IsString()
source?: string;
}
export class BatchCreateAttendanceDto {
@@ -78,6 +82,44 @@ export class QueryDingRawDto {
matchStatus?: string;
}
export class QueryAttendanceRecordsDto {
@IsOptional()
@IsInt()
@Type(() => Number)
classId?: number;
@IsOptional()
@IsDateString()
dateFrom?: string;
@IsOptional()
@IsDateString()
dateTo?: string;
@IsOptional()
@IsString()
session?: string;
@IsOptional()
@IsString()
@IsIn(['present', 'late', 'absent', 'leave'])
status?: string;
@IsOptional()
@IsString()
source?: string;
@IsOptional()
@IsInt()
@Type(() => Number)
page?: number;
@IsOptional()
@IsInt()
@Type(() => Number)
pageSize?: number;
}
export class MatchDingRecordDto {
@IsInt()
@IsNotEmpty()

View File

@@ -44,6 +44,9 @@ export class AttendanceRecord {
@Column({ length: 200, nullable: true })
remark: string;
@Column({ name: 'source', length: 20, default: 'manual' })
source: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;