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

@@ -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')

View File

@@ -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],

View File

@@ -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' } });
}