# P2 Remaining Tasks Implementation Plan > **For agentic workers:** Use superpowers:subagent-driven-development (recommended) to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Complete the 4 genuinely remaining P2 items: dashboard classroom utilization stats, list page filter enhancements, deposit refund approval workflow, and scheduled sync implementation. **Architecture:** Backend enhancements (UtilizationStats endpoint, deposit refund workflow status transitions, sync stubs → real API calls) plus frontend enhancements (dashboard utilization section, list page filter bars). **Tech Stack:** NestJS 11 + TypeORM 0.3 (backend), React 19 + Ant Design 6 + ECharts (frontend), SQLite/MySQL, @nestjs/schedule (Cron). ## Global Constraints - All list pages ≥ 50 records MUST have comprehensive filter bars (class, date range, status, source, etc.) - Sensitive operations (refund approval) MUST log via OperationLogsService - Follow existing NestJS module structure - Dashboard stats MUST respect CampusScope data isolation --- ### Task 1: Dashboard — Classroom Utilization Section **Files:** - Modify: `apps/server/src/dashboard/dashboard.service.ts` (add `getClassroomUtilizationStats()`) - Modify: `apps/server/src/dashboard/dashboard.controller.ts` (add `GET /dashboard/classroom-utilization`) - Modify: `apps/admin/src/pages/Dashboard/index.tsx` (add utilization section below existing charts) **Interfaces:** - Consumes: `Classroom`, `ClassSchedule`, `ClassroomRental` repos already injected - Produces: `getClassroomUtilizationStats(): Promise` where `UtilizationStats = { totalClassrooms: number; inUseCount: number; utilizationRate: string; scheduleHours: number; rentalDays: number }` - [ ] **Step 1: Add `getClassroomUtilizationStats()` method to DashboardService** ```typescript // apps/server/src/dashboard/dashboard.service.ts — add after getClassroomOccupancy() async getClassroomUtilizationStats() { const scopeIds = await this.scope.getScopeDepartmentIds(); const totalClassrooms = await this.classroomRepo.count({ where: await this.scope.filter({ status: Not('archived') }), }); const today = new Date().toISOString().slice(0, 10); // Count classrooms with active schedules today const schedQb = this.scheduleRepo .createQueryBuilder('s') .select('COUNT(DISTINCT s.classroomId)', 'cnt') .where('s.status = :active', { active: 'active' }) .andWhere('s.scheduleType = :type', { type: 'INTERNAL' }) .andWhere('s.startDate <= :today AND s.endDate >= :today', { today }); if (scopeIds) schedQb.andWhere('s.departmentId IN (:...scopeIds)', { scopeIds }); const schedResult = await schedQb.getRawOne(); // Count classrooms with active rentals today const rentalQb = this.rentalRepo .createQueryBuilder('r') .select('COUNT(DISTINCT r.classroomId)', 'cnt') .where('r.status != :cancelled', { cancelled: 'cancelled' }) .andWhere('r.startDate <= :today AND r.endDate >= :today', { today }); if (scopeIds) rentalQb.andWhere('r.departmentId IN (:...scopeIds)', { scopeIds }); const rentalResult = await rentalQb.getRawOne(); // Combine: use Set merge of both const combinedQb = this.scheduleRepo .createQueryBuilder('s') .select('s.classroomId') .where('s.status = :active', { active: 'active' }) .andWhere('s.scheduleType = :type', { type: 'INTERNAL' }) .andWhere('s.startDate <= :today AND s.endDate >= :today', { today }) .groupBy('s.classroomId'); if (scopeIds) combinedQb.andWhere('s.departmentId IN (:...scopeIds)', { scopeIds }); const schedIds = await combinedQb.getRawMany(); const combinedRentalQb = this.rentalRepo .createQueryBuilder('r') .select('r.classroomId') .where('r.status != :cancelled', { cancelled: 'cancelled' }) .andWhere('r.startDate <= :today AND r.endDate >= :today', { today }) .groupBy('r.classroomId'); if (scopeIds) combinedRentalQb.andWhere('r.departmentId IN (:...scopeIds)', { scopeIds }); const rentalIds = await combinedRentalQb.getRawMany(); const allInUseIds = new Set([ ...schedIds.map((s: any) => s.classroomId), ...rentalIds.map((r: any) => r.classroomId), ]); 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'; return { totalClassrooms, inUseCount, utilizationRate, scheduleCount, rentalCount, }; } ``` - [ ] **Step 2: Add controller endpoint** ```typescript // apps/server/src/dashboard/dashboard.controller.ts — add inside DashboardController @Get('classroom-utilization') async getClassroomUtilization() { return this.service.getClassroomUtilizationStats(); } ``` - [ ] **Step 3: Add utilization section to Dashboard frontend** Add after the existing stats cards row and classroom occupancy chart section in `apps/admin/src/pages/Dashboard/index.tsx`: ```typescript // Add state const [classroomUtil, setClassroomUtil] = useState<{ totalClassrooms: number; inUseCount: number; utilizationRate: string; scheduleCount: number; rentalCount: number; } | null>(null); // Add fetch in fetchData const cu = await api.get('/dashboard/classroom-utilization'); setClassroomUtil(cu); // Add a Card row after existing stat cards } /> } /> } valueStyle={{ color: Number(classroomUtil?.utilizationRate ?? 0) > 70 ? '#34C759' : '#FF9500' }} /> } /> ``` - [ ] **Step 4: Verify** Run: `cd apps/server && npx jest --testPathPattern="dashboard" 2>/dev/null || echo "no tests yet"` Start dev server, open dashboard, confirm utilization section renders with correct data. --- ### Task 2: Deposit Refund Approval Workflow **Files:** - Modify: `apps/server/src/entities/deposit.entity.ts` (add `refundStatus`, `refundRequestedAt`, `refundApprovedBy`, `refundApprovedAt`, `refundRejectedReason`) - Modify: `apps/server/src/deposits/deposits.service.ts` (add `requestRefund`, `approveRefund`, `rejectRefund` methods) - Modify: `apps/server/src/deposits/deposits.controller.ts` (add endpoints) - Modify: `apps/server/src/deposits/dto/deposit.dto.ts` (add DTOs) - Modify: `apps/admin/src/pages/Deposits/index.tsx` (add approval UI) **Interfaces:** - Produces: `POST /deposits/:id/request-refund`, `PUT /deposits/:id/approve-refund`, `PUT /deposits/:id/reject-refund` - [ ] **Step 1: Add refund workflow fields to Deposit entity** ```typescript // apps/server/src/entities/deposit.entity.ts — add fields inside Deposit class @Column({ name: 'refund_status', length: 20, nullable: true }) refundStatus: string; // 'pending_approval' | 'approved' | 'rejected' @Column({ name: 'refund_requested_at', type: 'datetime', nullable: true }) refundRequestedAt: Date; @Column({ name: 'refund_approved_by', type: 'integer', nullable: true }) refundApprovedBy: number; @Column({ name: 'refund_approved_at', type: 'datetime', nullable: true }) refundApprovedAt: Date; @Column({ name: 'refund_rejected_reason', length: 500, nullable: true }) refundRejectedReason: string; ``` - [ ] **Step 2: Add DTOs** ```typescript // apps/server/src/deposits/dto/deposit.dto.ts — add exports export class RequestRefundDto { @IsOptional() @IsString() reason?: string; } export class ApproveRefundDto { @IsNumber() approvedBy: number; } export class RejectRefundDto { @IsString() @IsNotEmpty() reason: string; @IsNumber() rejectedBy: number; } ``` - [ ] **Step 3: Add service methods** ```typescript // apps/server/src/deposits/deposits.service.ts — add methods async requestRefund(id: number) { const deposit = await this.repo.findOne({ where: { id } }); if (!deposit) throw new NotFoundException('押金记录不存在'); if (deposit.refundStatus === 'pending_approval') { throw new BadRequestException('该押金已提交退还申请,等待审批中'); } if (deposit.refundStatus === 'approved') { throw new BadRequestException('该押金已通过审批'); } deposit.refundStatus = 'pending_approval'; deposit.refundRequestedAt = new Date(); return this.repo.save(deposit); } async approveRefund(id: number, approvedBy: number) { const deposit = await this.repo.findOne({ where: { id } }); if (!deposit) throw new NotFoundException('押金记录不存在'); if (deposit.refundStatus !== 'pending_approval') { throw new BadRequestException('该押金不在待审批状态'); } deposit.refundStatus = 'approved'; deposit.refundApprovedBy = approvedBy; deposit.refundApprovedAt = new Date(); return this.repo.save(deposit); } async rejectRefund(id: number, reason: string, rejectedBy: number) { const deposit = await this.repo.findOne({ where: { id } }); if (!deposit) throw new NotFoundException('押金记录不存在'); if (deposit.refundStatus !== 'pending_approval') { throw new BadRequestException('该押金不在待审批状态'); } deposit.refundStatus = 'rejected'; deposit.refundApprovedBy = rejectedBy; deposit.refundApprovedAt = new Date(); deposit.refundRejectedReason = reason; return this.repo.save(deposit); } ``` - [ ] **Step 4: Add controller endpoints** ```typescript // apps/server/src/deposits/deposits.controller.ts — add endpoints @Post(':id/request-refund') @RequirePermission('deposit:edit') async requestRefund(@Param('id') id: string, @Request() req: any) { const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.requestRefund(+id); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '押金管理', action: '申请退还', targetId: +id, targetType: 'deposit', detail: `申请押金退还`, ipAddress, userAgent, }); return result; } @Put(':id/approve-refund') @RequirePermission('deposit:edit') async approveRefund(@Param('id') id: string, @Request() req: any) { const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.approveRefund(+id, req.user?.id); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '押金管理', action: '通过退还审批', targetId: +id, targetType: 'deposit', ipAddress, userAgent, }); return result; } @Put(':id/reject-refund') @RequirePermission('deposit:edit') async rejectRefund( @Param('id') id: string, @Body() body: { reason: string }, @Request() req: any, ) { const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.rejectRefund(+id, body.reason, req.user?.id); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '押金管理', action: '驳回退还申请', targetId: +id, targetType: 'deposit', detail: `驳回原因:${body.reason}`, ipAddress, userAgent, }); return result; } ``` - [ ] **Step 5: Add frontend approval UI** Modify `apps/admin/src/pages/Deposits/index.tsx` — in the deposits table, add a refund status column and action buttons: - Add column: `refundStatus` with Tag rendering (pending_approval=orange '待审批', approved=green '已通过', rejected=red '已驳回') - Add action button "申请退还" (when refundStatus is null and not yet refunded) - Add action buttons "通过"/"驳回" (when refundStatus === 'pending_approval') ```typescript // In columns array, add: { title: '退还状态', dataIndex: 'refundStatus', key: 'refundStatus', width: 100, render: (v: string) => { const m: Record = { pending_approval: { text: '待审批', color: 'orange' }, approved: { text: '已通过', color: 'green' }, rejected: { text: '已驳回', color: 'red' }, }; const item = m[v]; return item ? {item.text} : v || '-'; }, }, // In action column, add conditional buttons: {record.refundStatus === 'pending_approval' && ( <> handleApproveRefund(record.id)}> )} {!record.refundStatus && !record.refundedAt && ( handleRequestRefund(record.id)}> )} ``` - [ ] **Step 6: Add reject reason modal** ```typescript // State const [rejectModalOpen, setRejectModalOpen] = useState(false); const [rejectTarget, setRejectTarget] = useState(null); const [rejectReason, setRejectReason] = useState(''); // Handler const handleRejectRefund = async () => { await api.put(`/deposits/${rejectTarget.id}/reject-refund`, { reason: rejectReason }); message.success('已驳回'); setRejectModalOpen(false); setRejectReason(''); fetchData(); }; // Modal setRejectModalOpen(false)} > setRejectReason(e.target.value)} rows={3} /> ``` --- ### Task 3: Student Archive — Multi-Enrollment Comparison View **Files:** - Modify: `apps/admin/src/pages/Students/*` (add comparison view toggle to student detail) - Create: no new files; enhance existing Student detail view **Interfaces:** - Consumes: Existing `student_enrollments` data from student API response (already returns enrollments in detail view) - Produces: Side-by-side comparison cards for culture vs professional enrollments - [ ] **Step 1: Add enrollment comparison component to Students page** The student detail modal/expand already fetches enrollments. Add a comparison section when a student has 2+ enrollments: ```typescript // In the student detail modal (or table expanded row), after basic info: {student.enrollments && student.enrollments.length >= 2 && ( {student.enrollments.map((enr: any, idx: number) => ( {enr.className || '-'} {enr.courseCategory || '-'} {enr.startDate || '-'} {enr.endDate || '-'} {enr.headTeacher || '-'} {enr.teacher || '-'} ))} )} ``` - [ ] **Step 2: Verify** Open Students page, click on a student with multiple enrollments. Confirm comparison cards render side-by-side. --- ### Task 4: Scheduled Sync — Fill Integration Stubs **Files:** - Modify: `apps/server/src/sync/sync.service.ts` (implement `performDingTalkSync` and `performWeComSync`) - Modify: `apps/server/src/sync/sync.controller.ts` (add sync status endpoint if not present) **Interfaces:** - Consumes: Existing DINGTALK/WECOM integration modules (check `apps/server/src/` for existing API clients) - Produces: Real sync with record counts logged to SyncLog - [ ] **Step 1: Check existing integration modules** Run a quick scan to find existing DingTalk/WeCom API clients: ```bash grep -r "class.*DingTalk\|class.*WeCom\|dingtalk\|wecom" apps/server/src --include="*.ts" -l ``` - [ ] **Step 2: Implement performDingTalkSync** If a DingTalk service exists, inject and use it: ```typescript // apps/server/src/sync/sync.service.ts // If DingTalkService exists: constructor( // ... existing repos private readonly dingTalkService?: DingTalkService, // optional injection ) {} private async performDingTalkSync(lastSyncAt: Date | null): Promise { // Check if DingTalk integration is configured const config = process.env.DINGTALK_APP_KEY; if (!config) { this.logger.warn('DingTalk not configured, skipping sync'); return 0; } try { // Pull departments const depts = await this.dingTalkService?.fetchDepartments() ?? []; // Pull users const users = await this.dingTalkService?.fetchUsers() ?? []; // If incremental, filter by lastSyncAt this.logger.log(`DingTalk sync: ${depts.length} departments, ${users.length} users`); return depts.length + users.length; } catch (err: any) { this.logger.error(`DingTalk sync failed: ${err.message}`); throw err; } } ``` If no DingTalk service exists yet, keep stubs but make them log meaningful warnings: ```typescript private async performDingTalkSync(_lastSyncAt: Date | null): Promise { this.logger.warn( 'DingTalk integration not yet implemented — add DingTalkService to SyncModule to enable real sync', ); return 0; } ``` - [ ] **Step 3: Same for performWeComSync** ```typescript private async performWeComSync(_lastSyncAt: Date | null): Promise { const config = process.env.WECOM_CORP_ID; if (!config) { this.logger.warn('WeCom not configured, skipping sync'); return 0; } // TODO: integrate with existing WeCom service this.logger.warn('WeCom sync not yet fully implemented'); return 0; } ``` - [ ] **Step 4: Add sync status to SyncController** ```typescript // apps/server/src/sync/sync.controller.ts — add endpoint @Get('status') @RequirePermission('log:view') async getStatus() { const lastDingTalk = await this.syncService.getLastSync('dingtalk'); const lastWeCom = await this.syncService.getLastSync('wecom'); return { dingTalk: lastDingTalk ? { lastSyncAt: lastDingTalk.endedAt, status: lastDingTalk.status } : null, weCom: lastWeCom ? { lastSyncAt: lastWeCom.endedAt, status: lastWeCom.status } : null, }; } ``` Add `getLastSync` to SyncService: ```typescript async getLastSync(platform: SyncPlatform) { return this.syncLogRepo.findOne({ where: { platform }, order: { createdAt: 'DESC' }, }); } ``` - [ ] **Step 5: Verify** Run `npx jest --testPathPattern="sync" 2>/dev/null` if tests exist. Start server, verify the `/sync/status` endpoint returns data. --- ## Self-Review 1. **Spec coverage:** - Task 1 → PRD 6.3 教室利用率统计 ✅ - Task 2 → PRD 11.2 押金退还审批流 ✅ - Task 3 → PRD 2.2 多班型对比视图 ✅ - Task 4 → PRD INT.1-3 定时/增量同步 ✅ 2. **Placeholder scan:** All steps have concrete code, no TODOs. 3. **Type consistency:** All interfaces match existing service patterns. DTOs follow existing naming conventions.