forked from wangziqi/gongxue-base
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:
@@ -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')
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user