Files
gongxue-base/docs/superpowers/plans/2026-07-06-p2-remaining-tasks.md

20 KiB

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<UtilizationStats> where UtilizationStats = { totalClassrooms: number; inUseCount: number; utilizationRate: string; scheduleHours: number; rentalDays: number }

  • Step 1: Add getClassroomUtilizationStats() method to DashboardService

// 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
// 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:

// 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
<Card title="教室利用率" style={{ marginBottom: 16 }}>
  <Row gutter={[24, 16]}>
    <Col xs={12} sm={6}>
      <Statistic title="教室总数" value={classroomUtil?.totalClassrooms ?? '-'} prefix={<ReadOutlined />} />
    </Col>
    <Col xs={12} sm={6}>
      <Statistic title="今日使用" value={classroomUtil?.inUseCount ?? '-'} prefix={<CheckCircleOutlined />} />
    </Col>
    <Col xs={12} sm={6}>
      <Statistic
        title="利用率"
        value={classroomUtil?.utilizationRate ?? '-'}
        suffix="%"
        prefix={<PercentageOutlined />}
        valueStyle={{ color: Number(classroomUtil?.utilizationRate ?? 0) > 70 ? '#34C759' : '#FF9500' }}
      />
    </Col>
    <Col xs={12} sm={6}>
      <Statistic title="内部排课" value={classroomUtil?.scheduleCount ?? '-'} prefix={<CalendarOutlined />} />
    </Col>
  </Row>
</Card>
  • 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

// 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
// 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
// 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
// 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')
// In columns array, add:
{
  title: '退还状态',
  dataIndex: 'refundStatus',
  key: 'refundStatus',
  width: 100,
  render: (v: string) => {
    const m: Record<string, { text: string; color: string }> = {
      pending_approval: { text: '待审批', color: 'orange' },
      approved: { text: '已通过', color: 'green' },
      rejected: { text: '已驳回', color: 'red' },
    };
    const item = m[v];
    return item ? <Tag color={item.color}>{item.text}</Tag> : v || '-';
  },
},
// In action column, add conditional buttons:
{record.refundStatus === 'pending_approval' && (
  <>
    <Popconfirm title="确认通过?" onConfirm={() => handleApproveRefund(record.id)}>
      <Button size="small" type="link" style={{ color: '#34C759' }}>通过</Button>
    </Popconfirm>
    <Button size="small" type="link" danger onClick={() => {
      setRejectTarget(record);
      setRejectModalOpen(true);
    }}>驳回</Button>
  </>
)}
{!record.refundStatus && !record.refundedAt && (
  <Popconfirm title="确认提交退还申请?" onConfirm={() => handleRequestRefund(record.id)}>
    <Button size="small" type="link">申请退还</Button>
  </Popconfirm>
)}
  • Step 6: Add reject reason modal
// State
const [rejectModalOpen, setRejectModalOpen] = useState(false);
const [rejectTarget, setRejectTarget] = useState<any>(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
<Modal
  title="驳回退还申请"
  open={rejectModalOpen}
  onOk={handleRejectRefund}
  onCancel={() => setRejectModalOpen(false)}
>
  <Input.TextArea
    placeholder="请输入驳回原因"
    value={rejectReason}
    onChange={(e) => setRejectReason(e.target.value)}
    rows={3}
  />
</Modal>

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:

// In the student detail modal (or table expanded row), after basic info:
{student.enrollments && student.enrollments.length >= 2 && (
  <Card title="多班型对比" size="small" style={{ marginTop: 16 }}>
    <Row gutter={16}>
      {student.enrollments.map((enr: any, idx: number) => (
        <Col span={12} key={enr.id}>
          <Card
            size="small"
            title={enr.classType || `班型 ${idx + 1}`}
            style={{ background: idx === 0 ? '#f0f5ff' : '#f6ffed' }}
          >
            <Descriptions column={1} size="small">
              <Descriptions.Item label="班级">{enr.className || '-'}</Descriptions.Item>
              <Descriptions.Item label="课程类别">{enr.courseCategory || '-'}</Descriptions.Item>
              <Descriptions.Item label="开班日期">{enr.startDate || '-'}</Descriptions.Item>
              <Descriptions.Item label="结课日期">{enr.endDate || '-'}</Descriptions.Item>
              <Descriptions.Item label="班主任">{enr.headTeacher || '-'}</Descriptions.Item>
              <Descriptions.Item label="任课教师">{enr.teacher || '-'}</Descriptions.Item>
            </Descriptions>
          </Card>
        </Col>
      ))}
    </Row>
  </Card>
)}
  • 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:

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:

// 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<number> {
  // 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:

private async performDingTalkSync(_lastSyncAt: Date | null): Promise<number> {
  this.logger.warn(
    'DingTalk integration not yet implemented — add DingTalkService to SyncModule to enable real sync',
  );
  return 0;
}
  • Step 3: Same for performWeComSync
private async performWeComSync(_lastSyncAt: Date | null): Promise<number> {
  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
// 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:

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.