fix permissions and teacher attendance workflows
This commit is contained in:
@@ -1,17 +1,36 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { Controller, Get, Query, Request, UseGuards } from '@nestjs/common';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
|
||||
interface RequestUser {
|
||||
id: number;
|
||||
username: string;
|
||||
permissions?: string[];
|
||||
isSuperAdmin?: boolean;
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@RequirePermission('dashboard:view')
|
||||
@Controller('dashboard')
|
||||
export class DashboardController {
|
||||
constructor(private service: DashboardService) {}
|
||||
|
||||
private canManageAllDashboard(user: RequestUser): boolean {
|
||||
return (
|
||||
user.isSuperAdmin === true ||
|
||||
user.permissions?.includes('dashboard:manage') === true ||
|
||||
user.permissions?.includes('class:edit') === true
|
||||
);
|
||||
}
|
||||
|
||||
private getAccessibleClassIds(user: RequestUser) {
|
||||
return this.service.getAccessibleClassIds(user.id, this.canManageAllDashboard(user));
|
||||
}
|
||||
|
||||
@Get('stats')
|
||||
getStats() {
|
||||
return this.service.getStats();
|
||||
async getStats(@Request() req: { user: RequestUser }) {
|
||||
return this.service.getStats(await this.getAccessibleClassIds(req.user));
|
||||
}
|
||||
|
||||
@Get('gantt')
|
||||
@@ -40,8 +59,8 @@ export class DashboardController {
|
||||
}
|
||||
|
||||
@Get('class-attendance-ranking')
|
||||
getClassAttendanceRanking() {
|
||||
return this.service.getClassAttendanceRanking();
|
||||
async getClassAttendanceRanking(@Request() req: { user: RequestUser }) {
|
||||
return this.service.getClassAttendanceRanking(await this.getAccessibleClassIds(req.user));
|
||||
}
|
||||
|
||||
@Get('classroom-occupancy')
|
||||
@@ -53,4 +72,4 @@ export class DashboardController {
|
||||
async getClassroomUtilization() {
|
||||
return this.service.getClassroomUtilizationStats();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,28 @@ import { Class } from '../entities/class.entity';
|
||||
import { Deposit } from '../entities/deposit.entity';
|
||||
import { ClassroomRental } from '../entities/classroom-rental.entity';
|
||||
import { ClassTeacher } from '../entities/class-teacher.entity';
|
||||
import { ClassStudent } from '../entities/class-student.entity';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
import { DashboardController } from './dashboard.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Room, Student, Occupancy, Bill, RoomExpense, Classroom, ClassSchedule, AttendanceRecord, Class, Deposit, ClassroomRental, ClassTeacher])],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
Room,
|
||||
Student,
|
||||
Occupancy,
|
||||
Bill,
|
||||
RoomExpense,
|
||||
Classroom,
|
||||
ClassSchedule,
|
||||
AttendanceRecord,
|
||||
Class,
|
||||
Deposit,
|
||||
ClassroomRental,
|
||||
ClassTeacher,
|
||||
ClassStudent,
|
||||
]),
|
||||
],
|
||||
controllers: [DashboardController],
|
||||
providers: [DashboardService],
|
||||
})
|
||||
|
||||
44
apps/server/src/dashboard/dashboard.scope.spec.ts
Normal file
44
apps/server/src/dashboard/dashboard.scope.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { DashboardService } from './dashboard.service';
|
||||
|
||||
const createQb = () => ({
|
||||
leftJoin: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
groupBy: jest.fn().mockReturnThis(),
|
||||
addGroupBy: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
getRawMany: jest.fn().mockResolvedValue([]),
|
||||
getRawOne: jest.fn().mockResolvedValue({ cnt: '0' }),
|
||||
});
|
||||
|
||||
describe('DashboardService — teacher class scope', () => {
|
||||
it('filters class attendance ranking by assigned classes', async () => {
|
||||
const qb = createQb();
|
||||
const attendanceRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) };
|
||||
const service = new DashboardService(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
attendanceRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
await service.getClassAttendanceRanking([8, 9]);
|
||||
|
||||
expect(qb.andWhere).toHaveBeenCalledWith('a.classId IN (:...accessibleClassIds)', {
|
||||
accessibleClassIds: [8, 9],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, IsNull, Not, MoreThanOrEqual } from 'typeorm';
|
||||
import { Repository, IsNull, Not, MoreThanOrEqual, In } from 'typeorm';
|
||||
import { Room } from '../entities/room.entity';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { Occupancy } from '../entities/occupancy.entity';
|
||||
@@ -13,11 +13,10 @@ import { Class } from '../entities/class.entity';
|
||||
import { Deposit } from '../entities/deposit.entity';
|
||||
import { ClassroomRental } from '../entities/classroom-rental.entity';
|
||||
import { ClassTeacher } from '../entities/class-teacher.entity';
|
||||
|
||||
import { ClassStudent } from '../entities/class-student.entity';
|
||||
|
||||
@Injectable()
|
||||
export class DashboardService {
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Room) private roomRepo: Repository<Room>,
|
||||
@InjectRepository(Student) private studentRepo: Repository<Student>,
|
||||
@@ -31,15 +30,24 @@ export class DashboardService {
|
||||
@InjectRepository(Deposit) private depositRepo: Repository<Deposit>,
|
||||
@InjectRepository(ClassroomRental) private rentalRepo: Repository<ClassroomRental>,
|
||||
@InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,
|
||||
@InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,
|
||||
) {}
|
||||
|
||||
async getStats() {
|
||||
async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
|
||||
if (canManageAll) return undefined;
|
||||
const assignments = await this.classTeacherRepo.find({ where: { userId } });
|
||||
return [...new Set(assignments.map((assignment) => assignment.classId))];
|
||||
}
|
||||
|
||||
async getStats(accessibleClassIds?: number[]) {
|
||||
const today = new Date();
|
||||
const todayStr = today.toISOString().slice(0, 10);
|
||||
const currentMonth = todayStr.slice(0, 7); // YYYY-MM
|
||||
|
||||
const totalRooms = await this.roomRepo.count({ where: { status: Not('archived') } });
|
||||
const totalStudents = await this.studentRepo.count({ where: { status: 'active' } });
|
||||
const totalStudents = accessibleClassIds
|
||||
? await this.countStudentsInClasses(accessibleClassIds)
|
||||
: await this.studentRepo.count({ where: { status: 'active' } });
|
||||
const occupiedBeds = await this.occRepo.count({ where: { checkOutDate: IsNull() } });
|
||||
const capQb = this.roomRepo
|
||||
.createQueryBuilder('r')
|
||||
@@ -68,24 +76,22 @@ export class DashboardService {
|
||||
.andWhere('s.endDate >= :today', { today: todayStr });
|
||||
const occResult = await occQb.getRawOne();
|
||||
const occupiedClassrooms = parseInt(occResult?.cnt || '0', 10);
|
||||
const classroomOccupancyRate = classroomCount > 0
|
||||
? ((occupiedClassrooms / classroomCount) * 100).toFixed(1)
|
||||
: 0;
|
||||
const classroomOccupancyRate =
|
||||
classroomCount > 0 ? ((occupiedClassrooms / classroomCount) * 100).toFixed(1) : 0;
|
||||
|
||||
const attTodayQb = this.attendanceRepo
|
||||
.createQueryBuilder('a')
|
||||
.select('a.status', 'status')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('a.attendanceDate = :today', { today: todayStr })
|
||||
.groupBy('a.status');
|
||||
.where('a.attendanceDate = :today', { today: todayStr });
|
||||
this.applyClassScope(attTodayQb, 'a', accessibleClassIds);
|
||||
attTodayQb.groupBy('a.status');
|
||||
const attTodayStats = await attTodayQb.getRawMany();
|
||||
const todayTotal = attTodayStats.reduce((sum, r) => sum + parseInt(r.count, 10), 0);
|
||||
const todayPresent = attTodayStats
|
||||
.filter((r) => r.status === 'present')
|
||||
.reduce((sum, r) => sum + parseInt(r.count, 10), 0);
|
||||
const todayAttendanceRate = todayTotal > 0
|
||||
? ((todayPresent / todayTotal) * 100).toFixed(1)
|
||||
: 0;
|
||||
const todayAttendanceRate = todayTotal > 0 ? ((todayPresent / todayTotal) * 100).toFixed(1) : 0;
|
||||
|
||||
const incomeQb = this.billRepo
|
||||
.createQueryBuilder('b')
|
||||
@@ -96,11 +102,13 @@ export class DashboardService {
|
||||
const incomeResult = await incomeQb.getRawOne();
|
||||
const monthlyIncome = parseFloat(incomeResult?.total || '0');
|
||||
|
||||
const attendanceTrend = await this.getAttendanceTrend(todayStr);
|
||||
const attendanceTrend = await this.getAttendanceTrend(todayStr, accessibleClassIds);
|
||||
const incomeTrend = await this.getIncomeTrend(currentMonth);
|
||||
|
||||
// --- New stats ---
|
||||
const classCount = await this.classRepo.count({ where: {} });
|
||||
const classCount = accessibleClassIds
|
||||
? accessibleClassIds.length
|
||||
: await this.classRepo.count({ where: {} });
|
||||
|
||||
const teacherResult = await this.classTeacherRepo
|
||||
.createQueryBuilder('ct')
|
||||
@@ -116,7 +124,9 @@ export class DashboardService {
|
||||
const pendingResult = await pendingQb.getRawOne();
|
||||
const pendingDeposits = parseFloat(pendingResult?.total || '0');
|
||||
|
||||
const activeRentals = await this.rentalRepo.count({ where: { endDate: MoreThanOrEqual(todayStr) } });
|
||||
const activeRentals = await this.rentalRepo.count({
|
||||
where: { endDate: MoreThanOrEqual(todayStr) },
|
||||
});
|
||||
|
||||
const occByBldQb = this.occRepo
|
||||
.createQueryBuilder('o')
|
||||
@@ -126,10 +136,13 @@ export class DashboardService {
|
||||
.where('o.checkOutDate IS NULL');
|
||||
const occupancyByBuilding = await occByBldQb.groupBy('r.building').getRawMany();
|
||||
|
||||
const attendanceByStatus = attTodayStats.reduce((acc, r) => {
|
||||
acc[r.status] = parseInt(r.count, 10);
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
const attendanceByStatus = attTodayStats.reduce(
|
||||
(acc, r) => {
|
||||
acc[r.status] = parseInt(r.count, 10);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
);
|
||||
|
||||
const expByTypeQb = this.expRepo
|
||||
.createQueryBuilder('e')
|
||||
@@ -162,18 +175,39 @@ export class DashboardService {
|
||||
};
|
||||
}
|
||||
|
||||
private async getAttendanceTrend(todayStr: string) {
|
||||
private applyClassScope(
|
||||
qb: { andWhere: (condition: string, parameters?: Record<string, unknown>) => unknown },
|
||||
alias: string,
|
||||
accessibleClassIds?: number[],
|
||||
) {
|
||||
if (accessibleClassIds) {
|
||||
qb.andWhere(`${alias}.classId IN (:...accessibleClassIds)`, { accessibleClassIds });
|
||||
}
|
||||
}
|
||||
|
||||
private async countStudentsInClasses(accessibleClassIds: number[]) {
|
||||
if (accessibleClassIds.length === 0) return 0;
|
||||
const classStudents = await this.classStudentRepo.find({
|
||||
where: { classId: In(accessibleClassIds), status: 'active' },
|
||||
});
|
||||
return new Set(classStudents.map((item) => item.studentId)).size;
|
||||
}
|
||||
|
||||
private async getAttendanceTrend(todayStr: string, accessibleClassIds?: number[]) {
|
||||
const thirtyDaysAgo = new Date(todayStr);
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 29);
|
||||
const startStr = thirtyDaysAgo.toISOString().slice(0, 10);
|
||||
|
||||
const rows = await this.attendanceRepo
|
||||
const trendQb = this.attendanceRepo
|
||||
.createQueryBuilder('a')
|
||||
.select('a.attendanceDate', 'date')
|
||||
.addSelect('a.status', 'status')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('a.attendanceDate >= :start', { start: startStr })
|
||||
.andWhere('a.attendanceDate <= :today', { today: todayStr })
|
||||
.andWhere('a.attendanceDate <= :today', { today: todayStr });
|
||||
this.applyClassScope(trendQb, 'a', accessibleClassIds);
|
||||
|
||||
const rows = await trendQb
|
||||
.groupBy('a.attendanceDate')
|
||||
.addGroupBy('a.status')
|
||||
.orderBy('a.attendanceDate', 'ASC')
|
||||
@@ -235,7 +269,6 @@ export class DashboardService {
|
||||
.orderBy('room.roomNumber', 'ASC')
|
||||
.addOrderBy('o.checkInDate', 'ASC');
|
||||
|
||||
|
||||
if (query?.building) {
|
||||
qb.andWhere('room.building = :building', { building: query.building });
|
||||
}
|
||||
@@ -297,21 +330,24 @@ export class DashboardService {
|
||||
}
|
||||
|
||||
// 班级考勤排行
|
||||
async getClassAttendanceRanking() {
|
||||
async getClassAttendanceRanking(accessibleClassIds?: number[]) {
|
||||
if (accessibleClassIds?.length === 0) return { top: [], bottom: [] };
|
||||
const qb = this.attendanceRepo
|
||||
.createQueryBuilder('a')
|
||||
.leftJoin('a.class', 'class')
|
||||
.select('class.id', 'classId')
|
||||
.addSelect('class.name', 'className')
|
||||
.addSelect('a.status', 'status')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.groupBy('class.id').addGroupBy('class.name').addGroupBy('a.status');
|
||||
.addSelect('COUNT(*)', 'count');
|
||||
this.applyClassScope(qb, 'a', accessibleClassIds);
|
||||
qb.groupBy('class.id').addGroupBy('class.name').addGroupBy('a.status');
|
||||
const raw = await qb.getRawMany();
|
||||
|
||||
const classMap = new Map<number, { className: string; present: number; total: number }>();
|
||||
for (const r of raw) {
|
||||
if (!r.classId) continue;
|
||||
if (!classMap.has(Number(r.classId))) classMap.set(Number(r.classId), { className: r.className, present: 0, total: 0 });
|
||||
if (!classMap.has(Number(r.classId)))
|
||||
classMap.set(Number(r.classId), { className: r.className, present: 0, total: 0 });
|
||||
const entry = classMap.get(Number(r.classId))!;
|
||||
const n = parseInt(r.count, 10);
|
||||
entry.total += n;
|
||||
@@ -319,7 +355,10 @@ export class DashboardService {
|
||||
}
|
||||
|
||||
const ranked = Array.from(classMap.values())
|
||||
.map(e => ({ ...e, rate: e.total > 0 ? parseFloat(((e.present / e.total) * 100).toFixed(1)) : 0 }))
|
||||
.map((e) => ({
|
||||
...e,
|
||||
rate: e.total > 0 ? parseFloat(((e.present / e.total) * 100).toFixed(1)) : 0,
|
||||
}))
|
||||
.sort((a, b) => b.rate - a.rate);
|
||||
|
||||
return { top: ranked.slice(0, 5), bottom: ranked.slice(-5).reverse() };
|
||||
@@ -412,9 +451,8 @@ export class DashboardService {
|
||||
const scheduleCount = parseInt(schedResult?.cnt || '0', 10);
|
||||
const rentalCount = parseInt(rentalResult?.cnt || '0', 10);
|
||||
const inUseCount = allInUseIds.size;
|
||||
const utilizationRate = totalClassrooms > 0
|
||||
? ((inUseCount / totalClassrooms) * 100).toFixed(1)
|
||||
: '0';
|
||||
const utilizationRate =
|
||||
totalClassrooms > 0 ? ((inUseCount / totalClassrooms) * 100).toFixed(1) : '0';
|
||||
|
||||
return {
|
||||
totalClassrooms,
|
||||
@@ -424,4 +462,4 @@ export class DashboardService {
|
||||
rentalCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user