diff --git a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/data/DSL.kt b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/data/DSL.kt index 81afb95..215596b 100644 --- a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/data/DSL.kt +++ b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/data/DSL.kt @@ -1,8 +1,10 @@ package com.jaytux.grader.data +import kotlinx.datetime.* import org.jetbrains.exposed.dao.id.CompositeIdTable import org.jetbrains.exposed.dao.id.UUIDTable import org.jetbrains.exposed.sql.Table +import org.jetbrains.exposed.sql.kotlin.datetime.datetime object Courses : UUIDTable("courses") { val name = varchar("name", 50).uniqueIndex() @@ -53,12 +55,14 @@ object GroupAssignments : UUIDTable("grpAssgmts") { val editionId = reference("edition_id", Editions.id) val name = varchar("name", 50) val assignment = text("assignment") + val deadline = datetime("deadline") } object SoloAssignments : UUIDTable("soloAssgmts") { val editionId = reference("edition_id", Editions.id) val name = varchar("name", 50) val assignment = text("assignment") + val deadline = datetime("deadline") } object GroupFeedbacks : CompositeIdTable("grpFdbks") { diff --git a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/data/Database.kt b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/data/Database.kt index 3f0a272..ae42ef4 100644 --- a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/data/Database.kt +++ b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/data/Database.kt @@ -14,6 +14,14 @@ object Database { GroupAssignments, SoloAssignments, GroupFeedbacks, IndividualFeedbacks, SoloFeedbacks ) + + val addMissing = SchemaUtils.addMissingColumnsStatements( + Courses, Editions, Groups, + Students, GroupStudents, EditionStudents, + GroupAssignments, SoloAssignments, + GroupFeedbacks, IndividualFeedbacks, SoloFeedbacks + ) + addMissing.forEach { exec(it) } } actual } diff --git a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/data/Entities.kt b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/data/Entities.kt index b751029..01bf9fb 100644 --- a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/data/Entities.kt +++ b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/data/Entities.kt @@ -59,6 +59,7 @@ class GroupAssignment(id: EntityID) : Entity(id) { var edition by Edition referencedOn GroupAssignments.editionId var name by GroupAssignments.name var assignment by GroupAssignments.assignment + var deadline by GroupAssignments.deadline } class SoloAssignment(id: EntityID) : Entity(id) { @@ -67,6 +68,7 @@ class SoloAssignment(id: EntityID) : Entity(id) { var edition by Edition referencedOn SoloAssignments.editionId var name by SoloAssignments.name var assignment by SoloAssignments.assignment + var deadline by SoloAssignments.deadline } class GroupFeedback(id: EntityID) : Entity(id) { diff --git a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Assignments.kt b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Assignments.kt index a0bf156..19fbd4e 100644 --- a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Assignments.kt +++ b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Assignments.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material.OutlinedTextField import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -13,13 +12,20 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties import com.jaytux.grader.viewmodel.GroupAssignmentState +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.format +import kotlinx.datetime.format.FormatStringsInDatetimeFormats +import kotlinx.datetime.format.byUnicodePattern +@OptIn(ExperimentalMaterial3Api::class, FormatStringsInDatetimeFormats::class) @Composable fun GroupAssignmentView(state: GroupAssignmentState) { val (course, edition) = state.editionCourse val name by state.name val task by state.task + val deadline by state.deadline val allFeedback by state.feedback.entities var idx by remember { mutableStateOf(0) } @@ -45,11 +51,34 @@ fun GroupAssignmentView(state: GroupAssignmentState) { if(idx == 0) { var updTask by remember { mutableStateOf(task) } + Row { + var showPicker by remember { mutableStateOf(false) } + val dateState = rememberDatePickerState() + + Text("Deadline: ${deadline.format(LocalDateTime.Format { byUnicodePattern("dd/MM/yyyy - HH:mm") })}", Modifier.align(Alignment.CenterVertically)) + Spacer(Modifier.width(10.dp)) + Button({ showPicker = true }) { Text("Change") } + + if(showPicker) DatePickerDialog( + { showPicker = false }, + { Button({ showPicker = false; dateState.selectedDateMillis?.let { state.updateDeadline(it) } }) { Text("Set deadline") } }, + Modifier, + { Button({ showPicker = false }) { Text("Cancel") } }, + shape = MaterialTheme.shapes.medium, + tonalElevation = 10.dp, + colors = DatePickerDefaults.colors(), + properties = DialogProperties() + ) { + DatePicker( + dateState, + Modifier.fillMaxWidth().padding(10.dp), + ) + } + } OutlinedTextField(updTask, { updTask = it }, Modifier.fillMaxWidth().weight(1f), singleLine = false, minLines = 5, label = { Text("Task") }) CancelSaveRow(updTask != task, { updTask = task }, "Reset", "Update") { state.updateTask(updTask) } } else { - val (group, feedback, individual) = allFeedback[idx - 1].second groupFeedback(state, allFeedback[idx - 1].second) } } @@ -108,7 +137,24 @@ fun groupFeedback(state: GroupAssignmentState, fdbk: GroupAssignmentState.LocalG } } else { - // + val (student, details) = individual[idx - 1] + var sGrade by remember { mutableStateOf(details.second?.grade ?: "") } + var sMsg by remember { mutableStateOf(TextFieldValue(details.second?.feedback ?: "")) } + Row { + Text("Grade: ", Modifier.align(Alignment.CenterVertically)) + OutlinedTextField(sGrade, { sGrade = it }, Modifier.weight(0.2f)) + Spacer(Modifier.weight(0.6f)) + Button({ state.upsertIndividualFeedback(student, group, sMsg.text, sGrade) }, Modifier.weight(0.2f).align(Alignment.CenterVertically), + enabled = sGrade.isNotBlank() || sMsg.text.isNotBlank()) { + Text("Save") + } + } + + AutocompleteLineField( + sMsg, { sMsg = it }, Modifier.fillMaxWidth().weight(1f), { Text("Feedback") } + ) { filter -> + suggestions.filter { x -> x.trim().startsWith(filter.trim()) } + } } } } diff --git a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Courses.kt b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Courses.kt index fc372c4..a0c5190 100644 --- a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Courses.kt +++ b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Courses.kt @@ -2,11 +2,11 @@ package com.jaytux.grader.ui import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* -import androidx.compose.material.Icon -import androidx.compose.material.IconButton import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface diff --git a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Editions.kt b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Editions.kt index d7f717a..35b9fc9 100644 --- a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Editions.kt +++ b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Editions.kt @@ -4,13 +4,8 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material.Checkbox -import androidx.compose.material.OutlinedTextField -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface +import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.compose.material3.Text import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign @@ -41,6 +36,7 @@ fun EditionView(state: EditionState) = Row(Modifier.padding(0.dp)) { val groups by state.groups.entities val solo by state.solo.entities val groupAs by state.groupAs.entities + val available by state.availableStudents.entities val toggle = { i: Int, p: Panel -> idx = if(idx?.p == p && idx?.i == i) null else Current(p, i) @@ -76,7 +72,8 @@ fun EditionView(state: EditionState) = Row(Modifier.padding(0.dp)) { } else { Box(Modifier.weight(0.5f)) { StudentsWidget( - state.course, state.edition, students, idx.studentIdx(), { toggle(it, Panel.Student) } + state.course, state.edition, students, idx.studentIdx(), { toggle(it, Panel.Student) }, + available, { state.addToCourse(it) } ) { name, note, contact, addToEdition -> state.newStudent(name, contact, note, addToEdition) } @@ -135,12 +132,13 @@ fun EditionSideWidget( @Composable fun StudentsWidget( course: Course, edition: Edition, students: List, selected: Int?, onSelect: (Int) -> Unit, + availableStudents: List, onImport: (List) -> Unit, onAdd: (name: String, note: String, contact: String, addToEdition: Boolean) -> Unit ) = EditionSideWidget( course, edition, "Student list", "students", "a student", students, selected, onSelect, { Text(it.name, Modifier.padding(5.dp)) } ) { onExit -> - StudentDialog(course, edition, onExit, onAdd) + StudentDialog(course, edition, onExit, availableStudents, onImport, onAdd) } @Composable @@ -148,29 +146,93 @@ fun StudentDialog( course: Course, edition: Edition, onClose: () -> Unit, + availableStudents: List, + onImport: (List) -> Unit, onAdd: (name: String, note: String, contact: String, addToEdition: Boolean) -> Unit ) = DialogWindow( onCloseRequest = onClose, state = rememberDialogState(size = DpSize(600.dp, 400.dp), position = WindowPosition(Alignment.Center)) ) { - Surface(Modifier.fillMaxSize().padding(10.dp)) { - Box(Modifier.fillMaxSize()) { - var name by remember { mutableStateOf("") } - var contact by remember { mutableStateOf("") } - var note by remember { mutableStateOf("") } - var add by remember { mutableStateOf(true) } + Surface(Modifier.fillMaxSize()) { + Column(Modifier.padding(10.dp)) { + var isImport by remember { mutableStateOf(false) } + TabRow(if(isImport) 1 else 0) { + Tab(!isImport, { isImport = false }) { Text("Add new student") } + Tab(isImport, { isImport = true }) { Text("Add existing student") } + } - Column(Modifier.align(Alignment.Center)) { - OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth(), singleLine = true, label = { Text("Student name") }) - OutlinedTextField(contact, { contact = it }, Modifier.fillMaxWidth(), singleLine = true, label = { Text("Student contact") }) - OutlinedTextField(note, { note = it }, Modifier.fillMaxWidth(), singleLine = false, minLines = 3, label = { Text("Note") }) - Row { - Checkbox(add, { add = it }) - Text("Add student to ${course.name} ${edition.name}?", Modifier.align(Alignment.CenterVertically)) + if(isImport) { + if(availableStudents.isEmpty()) { + Box(Modifier.fillMaxSize()) { + Text("No students available to add to this course.", Modifier.align(Alignment.Center)) + } } - CancelSaveRow(name.isNotBlank() && contact.isNotBlank(), onClose) { - onAdd(name, note, contact, add) - onClose() + else { + var selected by remember { mutableStateOf(setOf()) } + + val onClick = { idx: Int -> + selected = if(idx in selected) selected - idx else selected + idx + } + + Text("Select students to add to ${course.name} ${edition.name}") + LazyColumn { + itemsIndexed(availableStudents) { idx, student -> + Surface( + Modifier.fillMaxWidth().clickable { onClick(idx) }, + tonalElevation = if (selected.contains(idx)) 5.dp else 0.dp + ) { + Row { + Checkbox(selected.contains(idx), { onClick(idx) }) + Text(student.name, Modifier.padding(5.dp)) + } + } + } + } + CancelSaveRow(selected.isNotEmpty(), onClose) { + onImport(selected.map { idx -> availableStudents[idx] }) + onClose() + } + } + } + else { + Box(Modifier.fillMaxSize()) { + var name by remember { mutableStateOf("") } + var contact by remember { mutableStateOf("") } + var note by remember { mutableStateOf("") } + var add by remember { mutableStateOf(true) } + + Column(Modifier.align(Alignment.Center)) { + OutlinedTextField( + name, + { name = it }, + Modifier.fillMaxWidth(), + singleLine = true, + label = { Text("Student name") }) + OutlinedTextField( + contact, + { contact = it }, + Modifier.fillMaxWidth(), + singleLine = true, + label = { Text("Student contact") }) + OutlinedTextField( + note, + { note = it }, + Modifier.fillMaxWidth(), + singleLine = false, + minLines = 3, + label = { Text("Note") }) + Row { + Checkbox(add, { add = it }) + Text( + "Add student to ${course.name} ${edition.name}?", + Modifier.align(Alignment.CenterVertically) + ) + } + CancelSaveRow(name.isNotBlank() && contact.isNotBlank(), onClose) { + onAdd(name, note, contact, add) + onClose() + } + } } } } diff --git a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Widgets.kt b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Widgets.kt index 2aca0a5..2f722c9 100644 --- a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Widgets.kt +++ b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Widgets.kt @@ -67,8 +67,8 @@ fun AddStringDialog(label: String, taken: List, onClose: () -> Unit, onS onCloseRequest = onClose, state = rememberDialogState(size = DpSize(400.dp, 300.dp), position = WindowPosition(Alignment.Center)) ) { - Surface(Modifier.fillMaxSize().padding(10.dp)) { - Box(Modifier.fillMaxSize()) { + Surface(Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize().padding(10.dp)) { var name by remember { mutableStateOf("") } Column(Modifier.align(Alignment.Center)) { androidx.compose.material.OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth(), label = { Text(label) }, isError = name in taken) diff --git a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/viewmodel/DbState.kt b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/viewmodel/DbState.kt index bdb091c..b452dfc 100644 --- a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/viewmodel/DbState.kt +++ b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/viewmodel/DbState.kt @@ -4,11 +4,18 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import com.jaytux.grader.data.* +import com.jaytux.grader.data.EditionStudents.editionId +import com.jaytux.grader.data.EditionStudents.studentId +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.transactions.transaction fun MutableState.immutable(): State = this +fun SizedIterable.sortAsc(vararg columns: Expression<*>) = this.orderBy(*(columns.map { it to SortOrder.ASC }.toTypedArray())) class RawDbState(private val loader: (Transaction.() -> List)) { @@ -23,7 +30,7 @@ class RawDbState(private val loader: (Transaction.() -> List)) { } class CourseListState { - val courses = RawDbState { Course.all().toList() } + val courses = RawDbState { Course.all().sortAsc(Courses.name).toList() } fun new(name: String) { transaction { Course.new { this.name = name } } @@ -39,7 +46,7 @@ class CourseListState { } class EditionListState(val course: Course) { - val editions = RawDbState { Edition.find { Editions.courseId eq course.id }.toList() } + val editions = RawDbState { Edition.find { Editions.courseId eq course.id }.sortAsc(Editions.name).toList() } fun new(name: String) { transaction { Edition.new { this.name = name; this.course = this@EditionListState.course } } @@ -54,10 +61,16 @@ class EditionListState(val course: Course) { class EditionState(val edition: Edition) { val course = transaction { edition.course } - val students = RawDbState { edition.soloStudents.toList() } - val groups = RawDbState { edition.groups.toList() } - val solo = RawDbState { edition.soloAssignments.toList() } - val groupAs = RawDbState { edition.groupAssignments.toList() } + val students = RawDbState { edition.soloStudents.sortAsc(Students.name).toList() } + val groups = RawDbState { edition.groups.sortAsc(Groups.name).toList() } + val solo = RawDbState { edition.soloAssignments.sortAsc(SoloAssignments.name).toList() } + val groupAs = RawDbState { edition.groupAssignments.sortAsc(GroupAssignments.name).toList() } + + val availableStudents = RawDbState { + Student.find { + (Students.id notInList edition.soloStudents.map { it.id }) + }.toList() + } fun newStudent(name: String, contact: String, note: String, addToEdition: Boolean) { transaction { @@ -69,6 +82,18 @@ class EditionState(val edition: Edition) { } if(addToEdition) students.refresh() + else availableStudents.refresh() + } + + fun addToCourse(students: List) { + transaction { + EditionStudents.batchInsert(students) { + this[editionId] = edition.id + this[studentId] = it.id + } + } + availableStudents.refresh(); + this.students.refresh() } fun newGroup(name: String) { @@ -94,8 +119,10 @@ class EditionState(val edition: Edition) { class StudentState(val student: Student, edition: Edition) { val editionCourse = transaction { edition.course to edition } - val groups = RawDbState { student.groups.map { it to (it.edition.course.name to it.edition.name) }.toList() } - val courseEditions = RawDbState { student.courses.map{ it to it.course }.toList() } + val groups = RawDbState { student.groups.sortAsc(Groups.name).map { it to (it.edition.course.name to it.edition.name) }.toList() } + val courseEditions = RawDbState { student.courses.map{ it to it.course }.sortedWith { + (e1, c1), (e2, c2) -> c1.name.compareTo(c2.name).let { if(it == 0) e1.name.compareTo(e2.name) else it } + }.toList() } fun update(f: Student.() -> Unit) { transaction { @@ -105,17 +132,17 @@ class StudentState(val student: Student, edition: Edition) { } class GroupState(val group: Group) { - val members = RawDbState { group.studentRoles.map{ it.student to it.role }.toList() } + val members = RawDbState { group.studentRoles.map{ it.student to it.role }.sortedBy { it.first.name }.toList() } val availableStudents = RawDbState { Student.find { // not yet in the group (Students.id notInList group.students.map { it.id }) and // but in the same course (edition) (Students.id inList group.edition.soloStudents.map { it.id }) - }.toList() } + }.sortAsc(Students.name).toList() } val course = transaction { group.edition.course to group.edition } val roles = RawDbState { GroupStudents.select(GroupStudents.role).where{ GroupStudents.role.isNotNull() } - .withDistinct(true).map{ it[GroupStudents.role] ?: "" }.toList() + .withDistinct(true).sortAsc(GroupStudents.role).map{ it[GroupStudents.role] ?: "" }.toList() } fun addStudent(student: Student) { @@ -151,24 +178,25 @@ class GroupAssignmentState(val assignment: GroupAssignment) { data class LocalGFeedback( val group: Group, val feedback: LocalFeedback?, - val individuals: Map> + val individuals: List>> ) val editionCourse = transaction { assignment.edition.course to assignment.edition } private val _name = mutableStateOf(assignment.name); val name = _name.immutable() private val _task = mutableStateOf(assignment.assignment); val task = _task.immutable() val feedback = RawDbState { loadFeedback() } + private val _deadline = mutableStateOf(assignment.deadline); val deadline = _deadline.immutable() val autofill = RawDbState { val forGroups = GroupFeedbacks.selectAll().where { GroupFeedbacks.groupAssignmentId eq assignment.id }.flatMap { it[GroupFeedbacks.feedback].split('\n') - }.toList() + } val forIndividuals = IndividualFeedbacks.selectAll().where { IndividualFeedbacks.groupAssignmentId eq assignment.id }.flatMap { it[IndividualFeedbacks.feedback].split('\n') - }.toList() + } - (forGroups + forIndividuals).distinct() + (forGroups + forIndividuals).distinct().sorted() } private fun Transaction.loadFeedback(): List> { @@ -186,8 +214,8 @@ class GroupAssignmentState(val assignment: GroupAssignment) { val groups = Group.find { (Groups.editionId eq assignment.edition.id) - }.map { group -> - val students = group.studentRoles.associate { sR -> + }.sortAsc(Groups.name).map { group -> + val students = group.studentRoles.sortedBy { it.student.name }.map { sR -> val student = sR.student val role = sR.role val feedback = individuals[student.id] @@ -234,6 +262,14 @@ class GroupAssignmentState(val assignment: GroupAssignment) { } _task.value = t } + + fun updateDeadline(instant: Long) { + val d = Instant.fromEpochMilliseconds(instant).toLocalDateTime(TimeZone.currentSystemDefault()) + transaction { + assignment.deadline = d + } + _deadline.value = d + } }