feat(admin): add potential fathers retrieval for character management
All checks were successful
Deploy to production / deploy (push) Successful in 2m47s

- Implemented a new method in AdminService to fetch potential fathers for a given character based on existing relationships.
- Updated AdminController to expose this functionality via a new API endpoint.
- Enhanced adminRouter to include the route for retrieving potential fathers.
- Modified frontend components to allow selection of potential fathers during pregnancy and birth management.
- Updated internationalization files to include new translation keys related to father selection.
This commit is contained in:
Torsten Schulz (local)
2026-03-31 08:50:56 +02:00
parent ee11a989a0
commit 9a78bc7c4b
30 changed files with 3907 additions and 45 deletions

View File

@@ -176,17 +176,33 @@
"statusActive": "Schwanger bis",
"statusNone": "Nicht schwanger",
"fatherId": "Vater-Charakter-ID (optional)",
"fatherSelect": "Vater (Ehepartner / Verlobter / Liebhaber)",
"fatherNone": "— kein Vater gespeichert —",
"fatherHintList": "Liste aus Beziehungen dieses Charakters (Ehe, Verlobung, aktive Liebschaft).",
"fatherHintManual": "Kein passender Partner in der Datenbank: Vater-Charakter-ID manuell eintragen.",
"fatherManualPlaceholder": "Charakter-ID",
"dueDays": "Tage bis zum Termin",
"dueDaysHint": "0 = Termin heute (Geburt kann je nach Spiel-Logik zeitnah anstehen).",
"force": "Schwangerschaft setzen",
"clear": "Schwangerschaft entfernen",
"successForce": "Schwangerschaft wurde gesetzt.",
"successClear": "Schwangerschaft wurde entfernt.",
"error": "Aktion fehlgeschlagen."
"error": "Aktion fehlgeschlagen.",
"relationship": {
"married": "Ehepartner",
"engaged": "Verlobter",
"lover": "Liebhaber"
}
},
"birth": {
"title": "Geburt erzwingen (Admin)",
"motherHint": "Es wird der oben genannte Charakter (Mutter) verwendet.",
"fatherId": "Vater-Charakter-ID",
"fatherSelect": "Vater (Ehepartner / Verlobter / Liebhaber)",
"fatherChoose": "— Vater wählen —",
"fatherHintList": "Liste aus Beziehungen dieses Charakters.",
"fatherHintManual": "Kein Partner in der Liste: Vater-Charakter-ID manuell eintragen.",
"fatherRequired": "Bitte einen Vater auswählen oder die Charakter-ID angeben.",
"context": "Kontext",
"contextMarriage": "Ehe",
"contextLover": "Liebschaft",

View File

@@ -231,17 +231,33 @@
"statusActive": "Expecting until",
"statusNone": "Not pregnant",
"fatherId": "Father character ID (optional)",
"fatherSelect": "Father (spouse / fiancé(e) / lover)",
"fatherNone": "— no father stored —",
"fatherHintList": "From this characters relationships (marriage, engagement, active affair).",
"fatherHintManual": "No matching partner in the database: enter the fathers character ID manually.",
"fatherManualPlaceholder": "Character ID",
"dueDays": "Days until due date",
"dueDaysHint": "0 = due today (birth may follow depending on game logic).",
"force": "Set pregnancy",
"clear": "Clear pregnancy",
"successForce": "Pregnancy has been set.",
"successClear": "Pregnancy has been cleared.",
"error": "Action failed."
"error": "Action failed.",
"relationship": {
"married": "Spouse",
"engaged": "Engaged partner",
"lover": "Lover"
}
},
"birth": {
"title": "Force birth (admin)",
"motherHint": "The character listed above is used as the mother.",
"fatherId": "Father character ID",
"fatherSelect": "Father (spouse / fiancé(e) / lover)",
"fatherChoose": "— choose father —",
"fatherHintList": "From this characters relationships.",
"fatherHintManual": "No partner in the list: enter the fathers character ID manually.",
"fatherRequired": "Please select a father or enter the character ID.",
"context": "Context",
"contextMarriage": "Marriage",
"contextLover": "Affair",

View File

@@ -176,17 +176,33 @@
"statusActive": "Embarazo hasta",
"statusNone": "No embarazada",
"fatherId": "ID del padre (opcional)",
"fatherSelect": "Padre (cónyuge / prometido / amante)",
"fatherNone": "— sin padre guardado —",
"fatherHintList": "Según las relaciones de este personaje (matrimonio, prometido, amante activo).",
"fatherHintManual": "Sin pareja adecuada en la base de datos: introduce manualmente el ID del padre.",
"fatherManualPlaceholder": "ID de personaje",
"dueDays": "Días hasta el parto previsto",
"dueDaysHint": "0 = parto previsto hoy (el nacimiento puede seguir según la lógica del juego).",
"force": "Establecer embarazo",
"clear": "Quitar embarazo",
"successForce": "Embarazo establecido.",
"successClear": "Embarazo eliminado.",
"error": "La acción ha fallado."
"error": "La acción ha fallado.",
"relationship": {
"married": "Cónyuge",
"engaged": "Prometido",
"lover": "Amante"
}
},
"birth": {
"title": "Forzar nacimiento (admin)",
"motherHint": "Se usa el personaje indicado arriba como madre.",
"fatherId": "ID del padre",
"fatherSelect": "Padre (cónyuge / prometido / amante)",
"fatherChoose": "— elegir padre —",
"fatherHintList": "Según las relaciones de este personaje.",
"fatherHintManual": "Sin pareja en la lista: introduce el ID del padre manualmente.",
"fatherRequired": "Elige un padre o introduce el ID de personaje.",
"context": "Contexto",
"contextMarriage": "Matrimonio",
"contextLover": "Amante",

View File

@@ -54,13 +54,28 @@
<template v-else>{{ $t('admin.falukant.edituser.pregnancy.statusNone') }}</template>
</p>
<label class="form-field">
{{ $t('admin.falukant.edituser.pregnancy.fatherId') }}
<input type="number" v-model.number="adminPregnancyFatherId" min="1" placeholder="—" />
{{ $t('admin.falukant.edituser.pregnancy.fatherSelect') }}
<select v-if="potentialFathers.length" v-model="adminPregnancyFatherSelect">
<option value="">{{ $t('admin.falukant.edituser.pregnancy.fatherNone') }}</option>
<option v-for="p in potentialFathers" :key="p.characterId" :value="String(p.characterId)">
{{ p.displayName }} {{ fatherRelationshipLabel(p.relationshipType) }}
</option>
</select>
<input
v-else
type="number"
v-model.number="adminPregnancyFatherManualId"
min="1"
:placeholder="$t('admin.falukant.edituser.pregnancy.fatherManualPlaceholder')"
/>
</label>
<p v-if="potentialFathers.length" class="admin-family-tools__hint">{{ $t('admin.falukant.edituser.pregnancy.fatherHintList') }}</p>
<p v-else class="admin-family-tools__hint">{{ $t('admin.falukant.edituser.pregnancy.fatherHintManual') }}</p>
<label class="form-field">
{{ $t('admin.falukant.edituser.pregnancy.dueDays') }}
<input type="number" v-model.number="adminDueInDays" min="1" max="365" />
<input type="number" v-model.number="adminDueInDays" min="0" max="365" />
</label>
<p class="admin-family-tools__hint">{{ $t('admin.falukant.edituser.pregnancy.dueDaysHint') }}</p>
<div class="action-buttons">
<button type="button" @click="adminForcePregnancy">{{ $t('admin.falukant.edituser.pregnancy.force') }}</button>
<button type="button" class="button-secondary" @click="adminClearPregnancy">{{ $t('admin.falukant.edituser.pregnancy.clear') }}</button>
@@ -69,9 +84,24 @@
<h4>{{ $t('admin.falukant.edituser.birth.title') }}</h4>
<p class="admin-family-tools__hint">{{ $t('admin.falukant.edituser.birth.motherHint') }}</p>
<label class="form-field">
{{ $t('admin.falukant.edituser.birth.fatherId') }} *
<input type="number" v-model.number="adminBirthFatherId" min="1" required />
{{ $t('admin.falukant.edituser.birth.fatherSelect') }} *
<select v-if="potentialFathers.length" v-model="adminBirthFatherSelect">
<option value="">{{ $t('admin.falukant.edituser.birth.fatherChoose') }}</option>
<option v-for="p in potentialFathers" :key="'b-' + p.characterId" :value="String(p.characterId)">
{{ p.displayName }} {{ fatherRelationshipLabel(p.relationshipType) }}
</option>
</select>
<input
v-else
type="number"
v-model.number="adminBirthFatherManualId"
min="1"
required
:placeholder="$t('admin.falukant.edituser.pregnancy.fatherManualPlaceholder')"
/>
</label>
<p v-if="potentialFathers.length" class="admin-family-tools__hint">{{ $t('admin.falukant.edituser.birth.fatherHintList') }}</p>
<p v-else class="admin-family-tools__hint">{{ $t('admin.falukant.edituser.birth.fatherHintManual') }}</p>
<label class="form-field">
{{ $t('admin.falukant.edituser.birth.context') }}
<select v-model="adminBirthContext">
@@ -220,9 +250,12 @@ export default {
branches: false,
stockTypes: false
},
adminPregnancyFatherId: null,
potentialFathers: [],
adminPregnancyFatherSelect: '',
adminPregnancyFatherManualId: null,
adminDueInDays: 21,
adminBirthFatherId: null,
adminBirthFatherSelect: '',
adminBirthFatherManualId: null,
adminBirthContext: 'marriage',
adminBirthLegitimacy: 'legitimate',
adminBirthGender: ''
@@ -300,6 +333,12 @@ export default {
this.originalAge = this.age;
this.users = [];
this.activeTab = 'userdata';
this.adminPregnancyFatherSelect = '';
this.adminPregnancyFatherManualId = null;
this.adminBirthFatherSelect = '';
this.adminBirthFatherManualId = null;
await this.loadPotentialFathers();
this.syncFatherSelectionsFromCharacter();
},
async saveUser() {
const dataToChange = {
@@ -432,11 +471,61 @@ export default {
this.originalUser = JSON.parse(JSON.stringify(this.editableUser));
this.age = Math.floor((Date.now() - new Date(this.editableUser.falukantData[0].character.birthdate)) / (24 * 60 * 60 * 1000));
this.originalAge = this.age;
await this.loadPotentialFathers();
this.syncFatherSelectionsFromCharacter();
},
fatherRelationshipLabel(relationshipType) {
const key = `admin.falukant.edituser.pregnancy.relationship.${relationshipType}`;
const t = this.$t(key);
return t === key ? relationshipType : t;
},
async loadPotentialFathers() {
const cid = this.editableUser?.falukantData?.[0]?.character?.id;
if (!cid) {
this.potentialFathers = [];
return;
}
try {
const r = await apiClient.get(`/api/admin/falukant/character/${cid}/potential-fathers`);
this.potentialFathers = Array.isArray(r.data?.options) ? r.data.options : [];
} catch (e) {
console.error('loadPotentialFathers', e);
this.potentialFathers = [];
}
},
syncFatherSelectionsFromCharacter() {
const c = this.editableUser?.falukantData?.[0]?.character;
if (!c) return;
const pf = c.pregnancyFatherCharacterId ?? c.pregnancy_father_character_id;
if (pf != null && this.potentialFathers.some((x) => x.characterId === Number(pf))) {
this.adminPregnancyFatherSelect = String(pf);
}
},
resolvePregnancyFatherCharacterId() {
if (this.potentialFathers.length) {
return this.adminPregnancyFatherSelect ? Number(this.adminPregnancyFatherSelect) : null;
}
return this.adminPregnancyFatherManualId ? Number(this.adminPregnancyFatherManualId) : null;
},
resolveBirthFatherCharacterId() {
if (this.potentialFathers.length) {
return this.adminBirthFatherSelect ? Number(this.adminBirthFatherSelect) : null;
}
return this.adminBirthFatherManualId ? Number(this.adminBirthFatherManualId) : null;
},
resolveDueInDays() {
const raw = this.adminDueInDays;
if (raw === '' || raw === null || raw === undefined) {
return 21;
}
const n = Number(raw);
return Number.isFinite(n) ? n : 21;
},
async adminForcePregnancy() {
const characterId = this.editableUser.falukantData[0].character.id;
const payload = { characterId, dueInDays: Number(this.adminDueInDays) || 21 };
if (this.adminPregnancyFatherId) payload.fatherCharacterId = Number(this.adminPregnancyFatherId);
const payload = { characterId, dueInDays: this.resolveDueInDays() };
const fatherId = this.resolvePregnancyFatherCharacterId();
if (fatherId != null) payload.fatherCharacterId = fatherId;
try {
await apiClient.post('/api/admin/falukant/character/force-pregnancy', payload);
showSuccess(this, 'tr:admin.falukant.edituser.pregnancy.successForce');
@@ -457,13 +546,14 @@ export default {
},
async adminForceBirth() {
const motherCharacterId = this.editableUser.falukantData[0].character.id;
if (!this.adminBirthFatherId) {
showError(this, this.$t('admin.falukant.edituser.birth.fatherId'));
const fatherCharacterId = this.resolveBirthFatherCharacterId();
if (fatherCharacterId == null || Number.isNaN(fatherCharacterId)) {
showError(this, this.$t('admin.falukant.edituser.birth.fatherRequired'));
return;
}
const body = {
motherCharacterId,
fatherCharacterId: Number(this.adminBirthFatherId),
fatherCharacterId,
birthContext: this.adminBirthContext,
legitimacy: this.adminBirthLegitimacy
};

View File

@@ -73,6 +73,12 @@
<span class="title-label">{{ lesson.title }}</span>
<span v-if="lesson.description" class="lesson-description">{{ lesson.description }}</span>
</div>
<div class="lesson-pedagogy" v-if="lesson.pedagogy">
<span class="lesson-chip lesson-chip--phase">{{ getPhaseLabel(lesson.pedagogy.phaseLabel) }}</span>
<span class="lesson-chip lesson-chip--mode">{{ getDidacticModeLabel(lesson.pedagogy.didacticMode) }}</span>
<span v-if="lesson.pedagogy.blockNumber" class="lesson-chip lesson-chip--block">Block {{ lesson.pedagogy.blockNumber }}</span>
<span v-if="lesson.pedagogy.isIntensiveReview" class="lesson-chip lesson-chip--intensive">Intensive Wiederholung</span>
</div>
<div class="lesson-actions-content">
<button
@click="openLesson(lesson.id)"
@@ -328,6 +334,36 @@ export default {
openLanguageAssistantSettings() {
this.$router.push('/settings/language-assistant');
},
getPhaseLabel(phaseLabel) {
switch (phaseLabel) {
case 'quickstart':
return 'Schnellstart';
case 'daily_life':
return 'Alltag';
case 'stabilization':
return 'Stabilisierung';
default:
return 'Lernphase';
}
},
getDidacticModeLabel(didacticMode) {
switch (didacticMode) {
case 'core_input':
return 'Neuer Stoff';
case 'guided_dialogue':
return 'Geführter Dialog';
case 'pattern_drill':
return 'Mustertraining';
case 'real_life_scenario':
return 'Alltagsszenario';
case 'intensive_review':
return 'Wiederholungsphase';
case 'checkpoint':
return 'Checkpoint';
default:
return 'Lerneinheit';
}
},
editLesson() {
showInfo(this, 'Die Bearbeitung einzelner Lektionen folgt noch.');
}
@@ -517,6 +553,42 @@ export default {
background: rgba(255, 255, 255, 0.7);
}
.lesson-pedagogy {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.lesson-chip {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.02em;
}
.lesson-chip--phase {
background: rgba(120, 195, 138, 0.16);
color: #42634e;
}
.lesson-chip--mode {
background: rgba(248, 162, 43, 0.16);
color: #8a5411;
}
.lesson-chip--block {
background: rgba(93, 64, 55, 0.09);
color: #6d5446;
}
.lesson-chip--intensive {
background: rgba(207, 78, 78, 0.14);
color: #a13f3f;
}
.lesson-card__header {
display: flex;
align-items: center;

View File

@@ -43,6 +43,14 @@
<span class="lesson-meta-label">{{ $t('socialnetwork.vocab.courses.lessonTypeLabel') }}</span>
<strong>{{ getLessonTypeLabel(lesson.lessonType) }}</strong>
</div>
<div class="lesson-meta-item" v-if="lessonPedagogy.phaseLabel">
<span class="lesson-meta-label">Phase</span>
<strong>{{ getPhaseLabel(lessonPedagogy.phaseLabel) }}</strong>
</div>
<div class="lesson-meta-item" v-if="lessonPedagogy.didacticMode">
<span class="lesson-meta-label">Fokus</span>
<strong>{{ getDidacticModeLabel(lessonPedagogy.didacticMode) }}</strong>
</div>
<div class="lesson-meta-item">
<span class="lesson-meta-label">{{ $t('socialnetwork.vocab.courses.recommendedDuration') }}</span>
<strong>{{ formatTargetMinutes(lesson.targetMinutes) }}</strong>
@@ -51,9 +59,22 @@
<span class="lesson-meta-label">{{ $t('socialnetwork.vocab.courses.exerciseLoad') }}</span>
<strong>{{ effectiveExercises?.length || 0 }} {{ $t('socialnetwork.vocab.courses.exercisesShort') }}</strong>
</div>
<div class="lesson-meta-item" v-if="lessonPedagogy.newUnitTarget">
<span class="lesson-meta-label">Neue Einheiten</span>
<strong>{{ lessonPedagogy.newUnitTarget }}</strong>
</div>
<div class="lesson-meta-item" v-if="lessonPedagogy.reviewWeight != null">
<span class="lesson-meta-label">Wiederholung</span>
<strong>{{ lessonPedagogy.reviewWeight }}%</strong>
</div>
</div>
</div>
<div v-if="lessonPedagogy.isIntensiveReview" class="lesson-intensity-banner">
<strong>Intensive Wiederholungsphase</strong>
<p>Diese Lektion priorisiert Wiederholung und Vertiefung. Neuer Stoff wird bewusst reduziert, damit vorhandene Muster stabil werden.</p>
</div>
<div class="learn-grid">
<div v-if="lesson && lesson.description" class="lesson-description-box">
<h4>{{ $t('socialnetwork.vocab.courses.lessonDescription') }}</h4>
@@ -872,6 +893,17 @@ export default {
practicalTasks: []
};
},
lessonPedagogy() {
return this.lesson?.pedagogy || {
didacticMode: null,
phaseLabel: null,
blockNumber: null,
difficultyWeight: null,
newUnitTarget: null,
reviewWeight: null,
isIntensiveReview: false
};
},
assistantAvailable() {
if (!this.assistantSettings) {
return false;
@@ -1280,6 +1312,36 @@ export default {
};
return labels[lessonType] || lessonType || this.$t('socialnetwork.vocab.courses.lessonTypeVocab');
},
getPhaseLabel(phaseLabel) {
switch (phaseLabel) {
case 'quickstart':
return 'Schnellstart';
case 'daily_life':
return 'Alltag';
case 'stabilization':
return 'Stabilisierung';
default:
return 'Lernphase';
}
},
getDidacticModeLabel(didacticMode) {
switch (didacticMode) {
case 'core_input':
return 'Neuer Stoff';
case 'guided_dialogue':
return 'Geführter Dialog';
case 'pattern_drill':
return 'Mustertraining';
case 'real_life_scenario':
return 'Alltagsszenario';
case 'intensive_review':
return 'Wiederholungsphase';
case 'checkpoint':
return 'Checkpoint';
default:
return 'Lernfokus';
}
},
formatTargetMinutes(targetMinutes) {
const minutes = Number(targetMinutes);
if (!minutes) {
@@ -2187,6 +2249,24 @@ export default {
color: #7a6848;
}
.lesson-intensity-banner {
margin-bottom: 18px;
padding: 14px 16px;
border-radius: 12px;
border: 1px solid rgba(207, 78, 78, 0.24);
background: rgba(255, 242, 242, 0.95);
color: #8e3d3d;
}
.lesson-intensity-banner strong,
.lesson-intensity-banner p {
margin: 0;
}
.lesson-intensity-banner p {
margin-top: 6px;
}
.learn-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));