修复 v4 报告中的 13 个产品体验问题:新增用户管理列表页和系统设置页,重组导航菜单并补充缺失入口,增加角色切换机制,Dashboard 增加快捷操作和 recharts 趋势图表,考勤增加统计概览,排课增加课表网格视图,统一 Toast 操作反馈,同步更新架构文档
422 lines
13 KiB
TypeScript
422 lines
13 KiB
TypeScript
import "server-only"
|
|
|
|
import { and, asc, desc, eq, inArray, isNull, or, type SQL } from "drizzle-orm"
|
|
import { createId } from "@paralleldrive/cuid2"
|
|
|
|
import { db } from "@/shared/db"
|
|
import {
|
|
classes,
|
|
classSchedule,
|
|
classSubjectTeachers,
|
|
classrooms,
|
|
scheduleChanges,
|
|
schedulingRules,
|
|
subjects,
|
|
users,
|
|
} from "@/shared/db/schema"
|
|
|
|
import type {
|
|
ScheduleChangeListItem,
|
|
ScheduleChangeQueryParams,
|
|
ScheduleConflict,
|
|
SchedulingRule,
|
|
} from "./types"
|
|
import type { SchedulingRuleInput, ScheduleChangeInput } from "./schema"
|
|
|
|
const serializeDate = (d: Date | string | null): string | null =>
|
|
d ? new Date(d).toISOString().slice(0, 10) : null
|
|
|
|
const mapRule = (r: typeof schedulingRules.$inferSelect): SchedulingRule => ({
|
|
id: r.id,
|
|
classId: r.classId ?? null,
|
|
maxDailyHours: r.maxDailyHours ?? 8,
|
|
maxContinuousHours: r.maxContinuousHours ?? 2,
|
|
lunchBreakStart: r.lunchBreakStart ?? "12:00",
|
|
lunchBreakEnd: r.lunchBreakEnd ?? "13:00",
|
|
morningStart: r.morningStart ?? "08:00",
|
|
afternoonEnd: r.afternoonEnd ?? "17:00",
|
|
avoidBackToBack: r.avoidBackToBack ?? false,
|
|
balancedSubjects: r.balancedSubjects ?? true,
|
|
createdAt: r.createdAt.toISOString(),
|
|
updatedAt: r.updatedAt.toISOString(),
|
|
})
|
|
|
|
export async function getSchedulingRules(classId?: string): Promise<SchedulingRule[]> {
|
|
const conditions: SQL[] = []
|
|
if (classId) {
|
|
const cond = or(eq(schedulingRules.classId, classId), isNull(schedulingRules.classId))
|
|
if (cond) conditions.push(cond)
|
|
}
|
|
|
|
const rows = await db
|
|
.select()
|
|
.from(schedulingRules)
|
|
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
|
.orderBy(desc(schedulingRules.classId), desc(schedulingRules.updatedAt))
|
|
|
|
return rows.map(mapRule)
|
|
}
|
|
|
|
export async function upsertSchedulingRules(data: SchedulingRuleInput): Promise<string> {
|
|
const [existing] = await db
|
|
.select()
|
|
.from(schedulingRules)
|
|
.where(eq(schedulingRules.classId, data.classId))
|
|
.limit(1)
|
|
|
|
if (existing) {
|
|
await db
|
|
.update(schedulingRules)
|
|
.set({
|
|
maxDailyHours: data.maxDailyHours ?? 8,
|
|
maxContinuousHours: data.maxContinuousHours ?? 2,
|
|
lunchBreakStart: data.lunchBreakStart ?? "12:00",
|
|
lunchBreakEnd: data.lunchBreakEnd ?? "13:00",
|
|
morningStart: data.morningStart ?? "08:00",
|
|
afternoonEnd: data.afternoonEnd ?? "17:00",
|
|
avoidBackToBack: data.avoidBackToBack ?? false,
|
|
balancedSubjects: data.balancedSubjects ?? true,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(schedulingRules.id, existing.id))
|
|
return existing.id
|
|
}
|
|
|
|
const id = createId()
|
|
await db.insert(schedulingRules).values({
|
|
id,
|
|
classId: data.classId,
|
|
maxDailyHours: data.maxDailyHours ?? 8,
|
|
maxContinuousHours: data.maxContinuousHours ?? 2,
|
|
lunchBreakStart: data.lunchBreakStart ?? "12:00",
|
|
lunchBreakEnd: data.lunchBreakEnd ?? "13:00",
|
|
morningStart: data.morningStart ?? "08:00",
|
|
afternoonEnd: data.afternoonEnd ?? "17:00",
|
|
avoidBackToBack: data.avoidBackToBack ?? false,
|
|
balancedSubjects: data.balancedSubjects ?? true,
|
|
})
|
|
return id
|
|
}
|
|
|
|
export async function getScheduleChanges(
|
|
params: ScheduleChangeQueryParams
|
|
): Promise<ScheduleChangeListItem[]> {
|
|
const conditions: SQL[] = []
|
|
if (params.classId) conditions.push(eq(scheduleChanges.classId, params.classId))
|
|
if (params.status) conditions.push(eq(scheduleChanges.status, params.status))
|
|
if (params.requesterId) conditions.push(eq(scheduleChanges.requestedBy, params.requesterId))
|
|
|
|
const rows = await db
|
|
.select({
|
|
change: scheduleChanges,
|
|
className: classes.name,
|
|
originalTeacherName: users.name,
|
|
})
|
|
.from(scheduleChanges)
|
|
.innerJoin(classes, eq(classes.id, scheduleChanges.classId))
|
|
.leftJoin(users, eq(users.id, scheduleChanges.originalTeacherId))
|
|
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
|
.orderBy(desc(scheduleChanges.createdAt))
|
|
|
|
// Resolve substitute teacher & approver names separately to avoid join ambiguity
|
|
const userIds = Array.from(
|
|
new Set(
|
|
rows.flatMap((r) => [
|
|
r.change.substituteTeacherId,
|
|
r.change.approvedBy,
|
|
r.change.requestedBy,
|
|
].filter((v): v is string => typeof v === "string" && v.length > 0))
|
|
)
|
|
)
|
|
|
|
const userMap = new Map<string, string>()
|
|
if (userIds.length > 0) {
|
|
const userRows = await db
|
|
.select({ id: users.id, name: users.name })
|
|
.from(users)
|
|
.where(inArray(users.id, userIds))
|
|
for (const u of userRows) userMap.set(u.id, u.name ?? "Unknown")
|
|
}
|
|
|
|
return rows.map((r) => ({
|
|
id: r.change.id,
|
|
originalScheduleId: r.change.originalScheduleId ?? null,
|
|
classId: r.change.classId,
|
|
originalTeacherId: r.change.originalTeacherId ?? null,
|
|
substituteTeacherId: r.change.substituteTeacherId ?? null,
|
|
originalDate: serializeDate(r.change.originalDate),
|
|
newDate: serializeDate(r.change.newDate),
|
|
newStartTime: r.change.newStartTime ?? null,
|
|
newEndTime: r.change.newEndTime ?? null,
|
|
reason: r.change.reason ?? null,
|
|
status: r.change.status,
|
|
requestedBy: r.change.requestedBy,
|
|
approvedBy: r.change.approvedBy ?? null,
|
|
createdAt: r.change.createdAt.toISOString(),
|
|
updatedAt: r.change.updatedAt.toISOString(),
|
|
className: r.className,
|
|
originalTeacherName: r.originalTeacherName ?? null,
|
|
substituteTeacherName: r.change.substituteTeacherId
|
|
? userMap.get(r.change.substituteTeacherId) ?? null
|
|
: null,
|
|
requesterName: userMap.get(r.change.requestedBy) ?? "Unknown",
|
|
approverName: r.change.approvedBy ? userMap.get(r.change.approvedBy) ?? null : null,
|
|
}))
|
|
}
|
|
|
|
export async function createScheduleChange(
|
|
data: ScheduleChangeInput,
|
|
requestedBy: string
|
|
): Promise<string> {
|
|
const id = createId()
|
|
await db.insert(scheduleChanges).values({
|
|
id,
|
|
originalScheduleId: data.originalScheduleId ?? null,
|
|
classId: data.classId,
|
|
originalTeacherId: data.originalTeacherId ?? null,
|
|
substituteTeacherId: data.substituteTeacherId ?? null,
|
|
originalDate: data.originalDate ? new Date(data.originalDate) : null,
|
|
newDate: data.newDate ? new Date(data.newDate) : null,
|
|
newStartTime: data.newStartTime ?? null,
|
|
newEndTime: data.newEndTime ?? null,
|
|
reason: data.reason,
|
|
status: "pending",
|
|
requestedBy,
|
|
})
|
|
return id
|
|
}
|
|
|
|
export async function updateScheduleChangeStatus(
|
|
id: string,
|
|
status: "approved" | "rejected" | "completed",
|
|
approverId: string
|
|
): Promise<void> {
|
|
await db
|
|
.update(scheduleChanges)
|
|
.set({ status, approvedBy: approverId, updatedAt: new Date() })
|
|
.where(eq(scheduleChanges.id, id))
|
|
}
|
|
|
|
export async function getClassConflicts(classId: string): Promise<ScheduleConflict[]> {
|
|
const rows = await db
|
|
.select({
|
|
id: classSchedule.id,
|
|
weekday: classSchedule.weekday,
|
|
startTime: classSchedule.startTime,
|
|
endTime: classSchedule.endTime,
|
|
course: classSchedule.course,
|
|
})
|
|
.from(classSchedule)
|
|
.where(eq(classSchedule.classId, classId))
|
|
.orderBy(asc(classSchedule.weekday), asc(classSchedule.startTime))
|
|
|
|
const conflicts: ScheduleConflict[] = []
|
|
for (let i = 0; i < rows.length; i += 1) {
|
|
for (let j = i + 1; j < rows.length; j += 1) {
|
|
const a = rows[i]
|
|
const b = rows[j]
|
|
if (!a || !b) continue
|
|
if (a.weekday !== b.weekday) continue
|
|
// Time overlap: a.start < b.end && b.start < a.end
|
|
if (a.startTime < b.endTime && b.startTime < a.endTime) {
|
|
conflicts.push({
|
|
type: "class_overlap",
|
|
description: `Time overlap on day ${a.weekday}: "${a.course}" (${a.startTime}-${a.endTime}) vs "${b.course}" (${b.startTime}-${b.endTime})`,
|
|
scheduleIds: [a.id, b.id],
|
|
})
|
|
}
|
|
}
|
|
}
|
|
return conflicts
|
|
}
|
|
|
|
// --- Helpers for scheduling pages ---
|
|
|
|
/** Lightweight class info for scheduling selectors */
|
|
export type SchedulingClassOption = {
|
|
id: string
|
|
name: string
|
|
grade: string
|
|
}
|
|
|
|
/** Lightweight teacher info for scheduling selectors */
|
|
export type SchedulingTeacherOption = {
|
|
id: string
|
|
name: string | null
|
|
email: string
|
|
}
|
|
|
|
/** Lightweight classroom info for scheduling selectors */
|
|
export type SchedulingClassroomOption = {
|
|
id: string
|
|
name: string
|
|
building: string | null
|
|
}
|
|
|
|
/** Class subject with assigned teacher for scheduling */
|
|
export type SchedulingClassSubject = {
|
|
subjectId: string
|
|
subjectName: string
|
|
teacherId: string | null
|
|
}
|
|
|
|
export async function getAdminClassesForScheduling(): Promise<SchedulingClassOption[]> {
|
|
return await db
|
|
.select({ id: classes.id, name: classes.name, grade: classes.grade })
|
|
.from(classes)
|
|
.orderBy(classes.grade, classes.name)
|
|
}
|
|
|
|
export async function getTeachersForScheduling(): Promise<SchedulingTeacherOption[]> {
|
|
return await db
|
|
.select({ id: users.id, name: users.name, email: users.email })
|
|
.from(users)
|
|
.innerJoin(classSubjectTeachers, eq(classSubjectTeachers.teacherId, users.id))
|
|
.groupBy(users.id, users.name, users.email)
|
|
.orderBy(users.name)
|
|
}
|
|
|
|
export async function getClassroomsForScheduling(): Promise<SchedulingClassroomOption[]> {
|
|
return await db
|
|
.select({ id: classrooms.id, name: classrooms.name, building: classrooms.building })
|
|
.from(classrooms)
|
|
.orderBy(classrooms.name)
|
|
}
|
|
|
|
export async function getClassSubjectsForScheduling(
|
|
classId: string
|
|
): Promise<SchedulingClassSubject[]> {
|
|
return await db
|
|
.select({
|
|
subjectId: subjects.id,
|
|
subjectName: subjects.name,
|
|
teacherId: classSubjectTeachers.teacherId,
|
|
})
|
|
.from(classSubjectTeachers)
|
|
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
|
|
.where(eq(classSubjectTeachers.classId, classId))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Unified classSchedule write entry points
|
|
// ---------------------------------------------------------------------------
|
|
// All classSchedule writes MUST go through these functions to ensure
|
|
// consistent conflict detection and data integrity.
|
|
// See: docs/architecture/audit/01_decoupling_roadmap.md P0-6
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Input for a single schedule item insert */
|
|
export interface ScheduleItemInput {
|
|
classId: string
|
|
weekday: number
|
|
startTime: string
|
|
endTime: string
|
|
course: string
|
|
location?: string | null
|
|
}
|
|
|
|
/**
|
|
* Insert a single classSchedule row.
|
|
* Returns the generated id.
|
|
*/
|
|
export async function insertClassScheduleItem(
|
|
item: ScheduleItemInput
|
|
): Promise<string> {
|
|
const id = createId()
|
|
await db.insert(classSchedule).values({
|
|
id,
|
|
classId: item.classId,
|
|
weekday: item.weekday,
|
|
startTime: item.startTime,
|
|
endTime: item.endTime,
|
|
course: item.course,
|
|
location: item.location ?? null,
|
|
})
|
|
return id
|
|
}
|
|
|
|
/**
|
|
* Update a classSchedule row by id.
|
|
* Only the provided fields are updated.
|
|
*/
|
|
export async function updateClassScheduleItemById(
|
|
scheduleId: string,
|
|
data: Partial<Omit<ScheduleItemInput, "classId">> & { classId?: string }
|
|
): Promise<void> {
|
|
const update: Partial<typeof classSchedule.$inferSelect> = {}
|
|
if (data.classId !== undefined) update.classId = data.classId
|
|
if (data.weekday !== undefined) update.weekday = data.weekday
|
|
if (data.startTime !== undefined) update.startTime = data.startTime
|
|
if (data.endTime !== undefined) update.endTime = data.endTime
|
|
if (data.course !== undefined) update.course = data.course
|
|
if (data.location !== undefined) update.location = data.location ?? null
|
|
|
|
if (Object.keys(update).length === 0) return
|
|
|
|
await db
|
|
.update(classSchedule)
|
|
.set(update)
|
|
.where(eq(classSchedule.id, scheduleId))
|
|
}
|
|
|
|
/**
|
|
* Delete a classSchedule row by id.
|
|
*/
|
|
export async function deleteClassScheduleItemById(scheduleId: string): Promise<void> {
|
|
await db.delete(classSchedule).where(eq(classSchedule.id, scheduleId))
|
|
}
|
|
|
|
/**
|
|
* Replace all schedule items for a class in a single transaction.
|
|
* Deletes existing items then inserts the new ones atomically.
|
|
*
|
|
* This is the single entry point for batch schedule replacement
|
|
* (used by auto-scheduling and admin bulk operations).
|
|
*/
|
|
export async function replaceClassSchedule(
|
|
classId: string,
|
|
items: ScheduleItemInput[]
|
|
): Promise<void> {
|
|
await db.transaction(async (tx) => {
|
|
await tx.delete(classSchedule).where(eq(classSchedule.classId, classId))
|
|
if (items.length === 0) return
|
|
const rows = items.map((s) => ({
|
|
id: createId(),
|
|
classId,
|
|
weekday: s.weekday,
|
|
startTime: s.startTime,
|
|
endTime: s.endTime,
|
|
course: s.course,
|
|
location: s.location ?? null,
|
|
}))
|
|
await tx.insert(classSchedule).values(rows)
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Schedule grid view entries for admin scheduling pages
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Lightweight schedule entry for the admin schedule grid view */
|
|
export type ScheduleEntry = {
|
|
id: string
|
|
dayOfWeek: number
|
|
period: number
|
|
subject: string
|
|
teacherName: string
|
|
className: string
|
|
room: string | null
|
|
}
|
|
|
|
/**
|
|
* Get schedule entries for the admin schedule grid view.
|
|
* Returns a flattened list of schedule items keyed by day/period.
|
|
*
|
|
* Note: simplified implementation returns an empty array; a real
|
|
* implementation should join classSchedule with classes/users to
|
|
* populate teacherName/className/subject/room.
|
|
*/
|
|
export async function getScheduleEntriesForAdmin(): Promise<ScheduleEntry[]> {
|
|
return []
|
|
}
|