feat(course): reset student course progress (#671)
* feat(course): add reset student course progress action Give course admins and tutors a destructive reset-progress flow on the student people detail page. The backend clears learner progress for one (courseId, memberId) pair in a single transaction while keeping the groupmember enrolled, and logs an audit summary. The dashboard shows a confirmation dialog with impact counts from analytics before deleting. Co-authored-by: Rotimi Best <[email protected]> * fix(i18n): add localized reset progress strings for all locales Co-authored-by: Rotimi Best <[email protected]> * fix(course): scope reset progress to course data only Stop deleting org-level community questions and answers during course progress reset. Fix course newsfeed deletion order so comments on a student's posts are removed before the posts themselves. Restrict reset to student members, move analytics loading to the person detail layout, and rename impact copy to course newsfeed only. Co-authored-by: Rotimi Best <[email protected]> * fix(course): address Greptile review on reset progress Make progressImpact opt-in via includeProgressImpact query param so analytics requests avoid extra DB work unless the reset dialog needs it. Count only student-authored course newsfeed posts and comments in the impact summary to avoid double-counting replies on the student's posts. Co-authored-by: Rotimi Best <[email protected]> --------- Co-authored-by: Cursor Agent <[email protected]> Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
co-authored by
Rotimi Best
Cursor Agent
parent
e43d8a2444
commit
a695c97eda
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import type { UserCourseAnalytics } from '$features/course/utils/types';
|
||||
import ResetProgressDialog from './reset-progress-dialog.svelte';
|
||||
|
||||
let {
|
||||
courseId,
|
||||
personId,
|
||||
userCourseAnalytics,
|
||||
onSuccess = () => {}
|
||||
}: {
|
||||
courseId: string;
|
||||
personId: string;
|
||||
userCourseAnalytics: UserCourseAnalytics;
|
||||
onSuccess?: () => void | Promise<void>;
|
||||
} = $props();
|
||||
|
||||
let dialogOpen = $state(false);
|
||||
|
||||
const memberId = $derived(courseApi.group.people.find((member) => member.profileId === personId)?.id ?? '');
|
||||
const studentName = $derived(userCourseAnalytics.user.fullName || userCourseAnalytics.user.email || '');
|
||||
const canReset = $derived(Boolean(memberId && userCourseAnalytics.progressImpact));
|
||||
</script>
|
||||
|
||||
<Button variant="destructive" onclick={() => (dialogOpen = true)} disabled={!canReset}>
|
||||
{$t('course.navItem.people.reset_progress.button')}
|
||||
</Button>
|
||||
|
||||
{#if canReset}
|
||||
<ResetProgressDialog bind:open={dialogOpen} {courseId} {memberId} {studentName} {userCourseAnalytics} {onSuccess} />
|
||||
{/if}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { peopleApi } from '$features/course/api/people.svelte';
|
||||
import type { UserCourseAnalytics } from '$features/course/utils/types';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
courseId,
|
||||
memberId,
|
||||
studentName,
|
||||
userCourseAnalytics,
|
||||
onSuccess = () => {}
|
||||
}: {
|
||||
open?: boolean;
|
||||
courseId: string;
|
||||
memberId: string;
|
||||
studentName: string;
|
||||
userCourseAnalytics: UserCourseAnalytics;
|
||||
onSuccess?: () => void | Promise<void>;
|
||||
} = $props();
|
||||
|
||||
const progressImpact = $derived(userCourseAnalytics.progressImpact);
|
||||
|
||||
const impactBullets = $derived.by(() => {
|
||||
if (!progressImpact) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'lessons',
|
||||
text: t.get('course.navItem.people.reset_progress.dialog.bullets.lessons', {
|
||||
completed: progressImpact.completedLessons,
|
||||
total: progressImpact.totalLessons
|
||||
})
|
||||
},
|
||||
{
|
||||
key: 'exercises',
|
||||
text: t.get('course.navItem.people.reset_progress.dialog.bullets.exercises', {
|
||||
count: progressImpact.exerciseSubmissions
|
||||
})
|
||||
},
|
||||
{
|
||||
key: 'comments',
|
||||
text: t.get('course.navItem.people.reset_progress.dialog.bullets.comments', {
|
||||
count: progressImpact.lessonComments
|
||||
})
|
||||
},
|
||||
{
|
||||
key: 'newsfeed',
|
||||
text: t.get('course.navItem.people.reset_progress.dialog.bullets.newsfeed', {
|
||||
count: progressImpact.courseNewsfeedActivity
|
||||
})
|
||||
},
|
||||
{
|
||||
key: 'video_progress',
|
||||
text: t.get('course.navItem.people.reset_progress.dialog.bullets.video_progress', {
|
||||
count: progressImpact.videoProgressLessons
|
||||
})
|
||||
},
|
||||
{
|
||||
key: 'attendance',
|
||||
text: t.get('course.navItem.people.reset_progress.dialog.bullets.attendance', {
|
||||
count: progressImpact.attendanceEntries
|
||||
})
|
||||
},
|
||||
{
|
||||
key: 'certificate',
|
||||
text: t.get(
|
||||
progressImpact.hasCertificationRecords
|
||||
? 'course.navItem.people.reset_progress.dialog.bullets.certificate_yes'
|
||||
: 'course.navItem.people.reset_progress.dialog.bullets.certificate_no'
|
||||
)
|
||||
}
|
||||
];
|
||||
});
|
||||
|
||||
async function handleReset() {
|
||||
if (!courseId || !memberId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await peopleApi.resetCourseProgress(courseId, memberId);
|
||||
|
||||
if (peopleApi.success) {
|
||||
open = false;
|
||||
await onSuccess();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-w-lg">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>
|
||||
{$t('course.navItem.people.reset_progress.dialog.title', { studentName })}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{$t('course.navItem.people.reset_progress.dialog.intro')}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<ul class="list-disc space-y-2 pl-5 text-sm">
|
||||
{#each impactBullets as bullet (bullet.key)}
|
||||
<li>{bullet.text}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<p class="ui:text-destructive mt-4 text-sm">
|
||||
{$t('course.navItem.people.reset_progress.dialog.warning')}
|
||||
</p>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => (open = false)} disabled={peopleApi.isLoading}>
|
||||
{$t('course.navItem.people.reset_progress.dialog.cancel')}
|
||||
</Button>
|
||||
<Button variant="destructive" onclick={handleReset} loading={peopleApi.isLoading} disabled={peopleApi.isLoading}>
|
||||
{$t('course.navItem.people.reset_progress.dialog.confirm')}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
Reference in New Issue
Block a user