480 lines
14 KiB
Vue
480 lines
14 KiB
Vue
<template>
|
|
<div class="admin-users">
|
|
<section class="admin-users__hero surface-card">
|
|
<span class="admin-users__eyebrow">Administration</span>
|
|
<h1>{{ $t('navigation.m-administration.users') }}</h1>
|
|
<p>Benutzer suchen, Kerndaten anpassen und Sperrstatus direkt im System pflegen.</p>
|
|
</section>
|
|
|
|
<section class="admin-users__search surface-card">
|
|
<AdminUserSearch @select="select" />
|
|
</section>
|
|
|
|
<section v-if="selected" class="edit surface-card">
|
|
<div class="edit__header">
|
|
<h2>{{ selected.username }}</h2>
|
|
<span class="edit__badge">{{ form.active ? 'Aktiv' : 'Gesperrt' }}</span>
|
|
</div>
|
|
|
|
<label class="edit__field">
|
|
<span>{{ $t('admin.user.name') }}</span>
|
|
<input v-model="form.username" type="text" />
|
|
</label>
|
|
|
|
<label class="edit__toggle">
|
|
<input type="checkbox" :checked="!form.active" @change="toggleBlocked($event)" />
|
|
<span>{{ $t('admin.user.blocked') }}</span>
|
|
</label>
|
|
|
|
<div class="actions">
|
|
<button @click="save">{{ $t('common.save') }}</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section v-if="selected" class="vocab-reset surface-card">
|
|
<h3 class="vocab-reset__title">{{ $t('admin.vocabLessonReset.title') }}</h3>
|
|
<p class="vocab-reset__intro">{{ $t('admin.vocabLessonReset.intro') }}</p>
|
|
|
|
<div class="vocab-reset__row">
|
|
<button
|
|
type="button"
|
|
class="vocab-reset__btn-secondary"
|
|
:disabled="loadingVocabCourses"
|
|
@click="loadVocabCourses"
|
|
>
|
|
{{ loadingVocabCourses ? $t('general.loading') : $t('admin.vocabLessonReset.loadCourses') }}
|
|
</button>
|
|
</div>
|
|
|
|
<label v-if="vocabCourses.length" class="edit__field">
|
|
<span>{{ $t('admin.vocabLessonReset.selectCourse') }}</span>
|
|
<select v-model="selectedVocabCourseId" @change="onVocabCourseChange">
|
|
<option value="">{{ $t('admin.vocabLessonReset.selectCourse') }}</option>
|
|
<option v-for="c in vocabCoursesForSelect" :key="c.id" :value="String(c.id)">{{ c.selectLabel }}</option>
|
|
</select>
|
|
</label>
|
|
|
|
<p v-else-if="vocabCoursesAttempted && !loadingVocabCourses && vocabLoadError" class="vocab-reset__hint">
|
|
{{ $t('admin.vocabLessonReset.loadCoursesError') }}
|
|
</p>
|
|
<p v-else-if="vocabCoursesAttempted && !loadingVocabCourses" class="vocab-reset__hint">
|
|
{{ $t('admin.vocabLessonReset.noEnrolledCourses') }}
|
|
</p>
|
|
|
|
<label v-if="vocabCourseLessons.length" class="edit__field">
|
|
<span>{{ $t('admin.vocabLessonReset.selectLesson') }}</span>
|
|
<select v-model="selectedVocabLessonId" :disabled="loadingVocabCourseDetail">
|
|
<option value="">
|
|
{{ loadingVocabCourseDetail ? $t('admin.vocabLessonReset.loadingLessons') : $t('admin.vocabLessonReset.selectLesson') }}
|
|
</option>
|
|
<option v-for="l in vocabCourseLessons" :key="l.id" :value="String(l.id)">
|
|
{{ l.lessonNumber }}. {{ l.title }}
|
|
</option>
|
|
</select>
|
|
</label>
|
|
|
|
<div class="vocab-reset__row">
|
|
<button
|
|
type="button"
|
|
:disabled="!selectedVocabLessonId || vocabResetSubmitting || loadingVocabCourseDetail"
|
|
@click="adminResetVocabLesson"
|
|
>
|
|
{{ vocabResetSubmitting ? $t('general.loading') : $t('admin.vocabLessonReset.reset') }}
|
|
</button>
|
|
</div>
|
|
|
|
<template v-if="vocabCourseLessons.length">
|
|
<p class="vocab-reset__divider">{{ $t('admin.vocabLessonMarkComplete.divider') }}</p>
|
|
<label class="edit__field">
|
|
<span>{{ $t('admin.vocabLessonMarkComplete.throughLabel') }}</span>
|
|
<input
|
|
v-model.number="vocabMarkThroughNumber"
|
|
type="number"
|
|
min="1"
|
|
:max="vocabMaxLessonNumber"
|
|
:disabled="loadingVocabCourseDetail || vocabMarkSubmitting"
|
|
class="vocab-reset__number"
|
|
/>
|
|
</label>
|
|
<p class="vocab-reset__hint">{{ $t('admin.vocabLessonMarkComplete.hint') }}</p>
|
|
<div class="vocab-reset__row">
|
|
<button
|
|
type="button"
|
|
:disabled="!canMarkVocabThrough || vocabMarkSubmitting || loadingVocabCourseDetail"
|
|
@click="adminMarkVocabLessonsThrough"
|
|
>
|
|
{{ vocabMarkSubmitting ? $t('general.loading') : $t('admin.vocabLessonMarkComplete.submit') }}
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</section>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
import apiClient from '@/utils/axios.js';
|
|
import AdminUserSearch from '@/components/admin/AdminUserSearch.vue';
|
|
|
|
export default {
|
|
name: 'AdminUsersView',
|
|
components: { AdminUserSearch },
|
|
data() {
|
|
return {
|
|
selected: null,
|
|
form: { username: '', active: true },
|
|
vocabCourses: [],
|
|
vocabCoursesAttempted: false,
|
|
selectedVocabCourseId: '',
|
|
vocabCourseLessons: [],
|
|
selectedVocabLessonId: '',
|
|
loadingVocabCourses: false,
|
|
loadingVocabCourseDetail: false,
|
|
vocabResetSubmitting: false,
|
|
vocabMarkThroughNumber: null,
|
|
vocabMarkSubmitting: false,
|
|
vocabLoadError: false
|
|
};
|
|
},
|
|
computed: {
|
|
/** Gleicher Titel bei mehreren Kurs-IDs → Kurs-ID anhängen (z. B. geklonte Kurse). */
|
|
vocabCoursesForSelect() {
|
|
const list = this.vocabCourses;
|
|
const counts = {};
|
|
list.forEach((c) => {
|
|
const t = (c.title || '').trim() || '—';
|
|
counts[t] = (counts[t] || 0) + 1;
|
|
});
|
|
return list.map((c) => {
|
|
const t = (c.title || '').trim() || '—';
|
|
const dup = counts[t] > 1;
|
|
return {
|
|
...c,
|
|
selectLabel: dup ? `${c.title} (#${c.id})` : (c.title || `#${c.id}`)
|
|
};
|
|
});
|
|
},
|
|
vocabMaxLessonNumber() {
|
|
const list = this.vocabCourseLessons;
|
|
if (!list.length) return 1;
|
|
return Math.max(...list.map((l) => Number(l.lessonNumber) || 0));
|
|
},
|
|
canMarkVocabThrough() {
|
|
const n = Number(this.vocabMarkThroughNumber);
|
|
return Number.isFinite(n) && n >= 1 && n <= this.vocabMaxLessonNumber;
|
|
}
|
|
},
|
|
methods: {
|
|
clearVocabResetUi() {
|
|
this.vocabCourses = [];
|
|
this.vocabCoursesAttempted = false;
|
|
this.vocabLoadError = false;
|
|
this.selectedVocabCourseId = '';
|
|
this.vocabCourseLessons = [];
|
|
this.selectedVocabLessonId = '';
|
|
this.vocabMarkThroughNumber = null;
|
|
},
|
|
async select(u) {
|
|
const res = await apiClient.get(`/api/admin/users/${u.id}`);
|
|
this.selected = res.data;
|
|
this.form.username = res.data.username;
|
|
this.form.active = !!res.data.active;
|
|
this.clearVocabResetUi();
|
|
},
|
|
toggleBlocked(e) {
|
|
this.form.active = !e.target.checked;
|
|
},
|
|
async save() {
|
|
if (!this.selected) return;
|
|
await apiClient.put(`/api/admin/users/${this.selected.id}`, {
|
|
username: this.form.username,
|
|
active: this.form.active
|
|
});
|
|
this.$root?.$refs?.messageDialog?.open?.('tr:common.saved');
|
|
},
|
|
async loadVocabCourses() {
|
|
if (!this.selected) {
|
|
this.$root?.$refs?.messageDialog?.open?.(this.$t('admin.vocabLessonReset.pickUserFirst'));
|
|
return;
|
|
}
|
|
this.loadingVocabCourses = true;
|
|
this.vocabCoursesAttempted = true;
|
|
this.vocabLoadError = false;
|
|
try {
|
|
const { data } = await apiClient.get(`/api/admin/users/${this.selected.id}/vocab-courses`);
|
|
this.vocabCourses = Array.isArray(data) ? data : [];
|
|
this.selectedVocabCourseId = '';
|
|
this.vocabCourseLessons = [];
|
|
this.selectedVocabLessonId = '';
|
|
this.vocabMarkThroughNumber = null;
|
|
} catch (e) {
|
|
console.error('[UsersView] vocab courses:', e);
|
|
this.vocabCourses = [];
|
|
this.vocabLoadError = true;
|
|
this.$root?.$refs?.messageDialog?.open?.(this.$t('admin.vocabLessonReset.error'));
|
|
} finally {
|
|
this.loadingVocabCourses = false;
|
|
}
|
|
},
|
|
async onVocabCourseChange() {
|
|
this.selectedVocabLessonId = '';
|
|
this.vocabMarkThroughNumber = null;
|
|
this.vocabCourseLessons = [];
|
|
if (!this.selectedVocabCourseId) {
|
|
return;
|
|
}
|
|
this.loadingVocabCourseDetail = true;
|
|
try {
|
|
const { data } = await apiClient.get(`/api/admin/vocab/courses/${this.selectedVocabCourseId}`);
|
|
const lessons = data?.lessons || [];
|
|
this.vocabCourseLessons = [...lessons].sort((a, b) => {
|
|
if (a.weekNumber !== b.weekNumber) {
|
|
return (a.weekNumber || 999) - (b.weekNumber || 999);
|
|
}
|
|
if (a.dayNumber !== b.dayNumber) {
|
|
return (a.dayNumber || 999) - (b.dayNumber || 999);
|
|
}
|
|
return (a.lessonNumber || 0) - (b.lessonNumber || 0);
|
|
});
|
|
} catch (e) {
|
|
console.error('[UsersView] vocab course detail:', e);
|
|
this.vocabCourseLessons = [];
|
|
this.$root?.$refs?.messageDialog?.open?.(this.$t('admin.vocabLessonReset.error'));
|
|
} finally {
|
|
this.loadingVocabCourseDetail = false;
|
|
}
|
|
},
|
|
adminResetVocabLesson() {
|
|
if (!this.selected || !this.selectedVocabLessonId || this.vocabResetSubmitting) {
|
|
return;
|
|
}
|
|
const lesson = this.vocabCourseLessons.find((l) => String(l.id) === String(this.selectedVocabLessonId));
|
|
const lessonLabel = lesson ? `${lesson.lessonNumber}. ${lesson.title}` : this.selectedVocabLessonId;
|
|
const msg = this.$t('admin.vocabLessonReset.confirm', {
|
|
lesson: lessonLabel,
|
|
username: this.selected.username
|
|
});
|
|
if (!window.confirm(msg)) {
|
|
return;
|
|
}
|
|
this.runAdminVocabReset();
|
|
},
|
|
async runAdminVocabReset() {
|
|
this.vocabResetSubmitting = true;
|
|
try {
|
|
await apiClient.post(`/api/admin/users/${this.selected.id}/vocab-lesson-progress/reset`, {
|
|
lessonId: Number(this.selectedVocabLessonId)
|
|
});
|
|
this.$root?.$refs?.messageDialog?.open?.('tr:admin.vocabLessonReset.success');
|
|
} catch (e) {
|
|
console.error('[UsersView] admin vocab reset:', e);
|
|
this.$root?.$refs?.messageDialog?.open?.(this.$t('admin.vocabLessonReset.error'));
|
|
} finally {
|
|
this.vocabResetSubmitting = false;
|
|
}
|
|
},
|
|
adminMarkVocabLessonsThrough() {
|
|
if (!this.selected || !this.selectedVocabCourseId || !this.canMarkVocabThrough || this.vocabMarkSubmitting) {
|
|
return;
|
|
}
|
|
const n = Number(this.vocabMarkThroughNumber);
|
|
const msg = this.$t('admin.vocabLessonMarkComplete.confirm', {
|
|
n,
|
|
username: this.selected.username
|
|
});
|
|
if (!window.confirm(msg)) {
|
|
return;
|
|
}
|
|
this.runAdminVocabMarkThrough();
|
|
},
|
|
async runAdminVocabMarkThrough() {
|
|
this.vocabMarkSubmitting = true;
|
|
try {
|
|
const { data } = await apiClient.post(
|
|
`/api/admin/users/${this.selected.id}/vocab-lesson-progress/mark-complete-through`,
|
|
{
|
|
courseId: Number(this.selectedVocabCourseId),
|
|
throughLessonNumber: Number(this.vocabMarkThroughNumber)
|
|
}
|
|
);
|
|
const details = Array.isArray(data?.details) ? data.details : [];
|
|
const marked = details.filter((d) => d.status === 'marked_complete').length;
|
|
const unchanged = details.filter((d) => d.status === 'unchanged').length;
|
|
const msgKey =
|
|
marked === 0 && unchanged > 0
|
|
? 'admin.vocabLessonMarkComplete.successNone'
|
|
: 'admin.vocabLessonMarkComplete.success';
|
|
this.$root?.$refs?.messageDialog?.open?.(this.$t(msgKey, { marked, unchanged }));
|
|
} catch (e) {
|
|
console.error('[UsersView] admin vocab mark complete:', e);
|
|
this.$root?.$refs?.messageDialog?.open?.(this.$t('admin.vocabLessonMarkComplete.error'));
|
|
} finally {
|
|
this.vocabMarkSubmitting = false;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<style scoped>
|
|
.admin-users {
|
|
display: grid;
|
|
gap: 18px;
|
|
}
|
|
|
|
.admin-users__hero,
|
|
.admin-users__search,
|
|
.edit,
|
|
.vocab-reset {
|
|
background: linear-gradient(180deg, rgba(255, 252, 247, 0.97), rgba(250, 244, 235, 0.95));
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-lg);
|
|
box-shadow: var(--shadow-soft);
|
|
}
|
|
|
|
.admin-users__hero,
|
|
.admin-users__search,
|
|
.edit,
|
|
.vocab-reset {
|
|
padding: 22px 24px;
|
|
}
|
|
|
|
.admin-users__eyebrow {
|
|
display: inline-flex;
|
|
margin-bottom: 8px;
|
|
padding: 4px 10px;
|
|
border-radius: var(--radius-pill);
|
|
background: var(--color-secondary-soft);
|
|
color: var(--color-text-secondary);
|
|
font-size: 0.78rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.admin-users__hero p {
|
|
margin: 0;
|
|
color: var(--color-text-secondary);
|
|
}
|
|
|
|
.edit {
|
|
display: grid;
|
|
gap: 14px;
|
|
max-width: 560px;
|
|
}
|
|
|
|
.vocab-reset {
|
|
display: grid;
|
|
gap: 14px;
|
|
max-width: 560px;
|
|
}
|
|
|
|
.vocab-reset__title {
|
|
margin: 0;
|
|
font-size: 1.15rem;
|
|
}
|
|
|
|
.vocab-reset__intro,
|
|
.vocab-reset__hint {
|
|
margin: 0;
|
|
color: var(--color-text-secondary);
|
|
font-size: 0.92rem;
|
|
line-height: 1.45;
|
|
}
|
|
|
|
.vocab-reset__divider {
|
|
margin: 8px 0 0;
|
|
padding-top: 14px;
|
|
border-top: 1px dashed var(--color-border);
|
|
color: var(--color-text-secondary);
|
|
font-size: 0.88rem;
|
|
}
|
|
|
|
.vocab-reset__number {
|
|
max-width: 120px;
|
|
}
|
|
|
|
.vocab-reset__row {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 10px;
|
|
}
|
|
|
|
.vocab-reset__btn-secondary {
|
|
padding: 8px 14px;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md, 6px);
|
|
background: rgba(255, 255, 255, 0.9);
|
|
cursor: pointer;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.edit__header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
}
|
|
|
|
.edit__badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
min-height: 32px;
|
|
padding: 0 12px;
|
|
border-radius: var(--radius-pill);
|
|
background: var(--color-primary-soft);
|
|
color: var(--color-text-secondary);
|
|
font-weight: 700;
|
|
}
|
|
|
|
.edit__field {
|
|
display: grid;
|
|
gap: 8px;
|
|
}
|
|
|
|
.edit__field span {
|
|
font-weight: 600;
|
|
color: var(--color-text-secondary);
|
|
}
|
|
|
|
.edit__field input,
|
|
.edit__field select {
|
|
width: 100%;
|
|
max-width: 100%;
|
|
box-sizing: border-box;
|
|
padding: 10px 12px;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md, 6px);
|
|
background: #fff;
|
|
}
|
|
|
|
.edit__toggle {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
color: var(--color-text-secondary);
|
|
}
|
|
|
|
.actions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
@media (max-width: 760px) {
|
|
.admin-users__hero,
|
|
.admin-users__search,
|
|
.edit,
|
|
.vocab-reset {
|
|
padding: 18px;
|
|
}
|
|
|
|
.edit__header {
|
|
align-items: flex-start;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.actions button {
|
|
width: 100%;
|
|
}
|
|
}
|
|
</style>
|