added lern and skill improvement cost editing
This commit is contained in:
@@ -1,3 +1,11 @@
|
||||
Ich möchte eine oder zwei Seiten zur Einstellung und Verwaltung von Lernkosten für Charakterklassen und Fertigkeitskategorien als auch von lernkosten pro Schwierigkeit und TE pro Schwierigkeit und Stufe erstellen. Beispiele für Originallisten findest Du in Lern_und_Trainingslisten.md
|
||||
Die nötigen Datenstrukturen im Backend sind vorhanden. Erstelle wenn nötig passende API Endpunkte inclusive der dazu gehörigen Tests.
|
||||
Erstelle vor allem die views und Componenten für das Frontend.
|
||||
Plane gründlich prüfe das Ergebnis sogfältig.
|
||||
Du darfst Subagents starten wenn das notwendig oder sinnvoll ist
|
||||
|
||||
# --------------
|
||||
|
||||
move the model in this file to the appropiate package.
|
||||
register everything via the init function
|
||||
Take care that no circle references are created during this process.
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
package maintenance
|
||||
|
||||
import (
|
||||
"bamort/bmrt/models"
|
||||
"bamort/database"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// --- Character Classes ---
|
||||
|
||||
func GetCharacterClasses(c *gin.Context) {
|
||||
gs := resolveGameSystemOrDefault(c)
|
||||
if gs == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var classes []models.CharacterClass
|
||||
if err := database.DB.Where("game_system=? OR game_system_id=?", gs.Name, gs.ID).
|
||||
Order("code ASC").
|
||||
Find(&classes).Error; err != nil {
|
||||
respondWithError(c, http.StatusInternalServerError, "Failed to retrieve character classes")
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"character_classes": classes})
|
||||
}
|
||||
|
||||
// --- Skill Categories ---
|
||||
|
||||
func GetSkillCategories(c *gin.Context) {
|
||||
gs := resolveGameSystemOrDefault(c)
|
||||
if gs == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var categories []models.SkillCategory
|
||||
if err := database.DB.Where("game_system=? OR game_system_id=?", gs.Name, gs.ID).
|
||||
Order("name ASC").
|
||||
Find(&categories).Error; err != nil {
|
||||
respondWithError(c, http.StatusInternalServerError, "Failed to retrieve skill categories")
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"skill_categories": categories})
|
||||
}
|
||||
|
||||
// --- Skill Difficulties ---
|
||||
|
||||
func GetSkillDifficulties(c *gin.Context) {
|
||||
gs := resolveGameSystemOrDefault(c)
|
||||
if gs == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var difficulties []models.SkillDifficulty
|
||||
if err := database.DB.Where("game_system=? OR game_system_id=?", gs.Name, gs.ID).
|
||||
Order("id ASC").
|
||||
Find(&difficulties).Error; err != nil {
|
||||
respondWithError(c, http.StatusInternalServerError, "Failed to retrieve skill difficulties")
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"skill_difficulties": difficulties})
|
||||
}
|
||||
|
||||
// --- Spell Schools ---
|
||||
|
||||
func GetSpellSchools(c *gin.Context) {
|
||||
gs := resolveGameSystemOrDefault(c)
|
||||
if gs == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var schools []models.SpellSchool
|
||||
if err := database.DB.Where("game_system=? OR game_system_id=?", gs.Name, gs.ID).
|
||||
Order("name ASC").
|
||||
Find(&schools).Error; err != nil {
|
||||
respondWithError(c, http.StatusInternalServerError, "Failed to retrieve spell schools")
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"spell_schools": schools})
|
||||
}
|
||||
|
||||
// --- Class Category EP Costs (EP per TE) ---
|
||||
|
||||
func GetClassCategoryEPCosts(c *gin.Context) {
|
||||
var costs []models.ClassCategoryEPCost
|
||||
if err := database.DB.
|
||||
Preload("CharacterClass").
|
||||
Preload("SkillCategory").
|
||||
Order("character_class ASC, skill_category ASC").
|
||||
Find(&costs).Error; err != nil {
|
||||
respondWithError(c, http.StatusInternalServerError, "Failed to retrieve class category EP costs")
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"costs": costs})
|
||||
}
|
||||
|
||||
type classCategoryEPCostUpdateRequest struct {
|
||||
EPPerTE *int `json:"ep_per_te"`
|
||||
}
|
||||
|
||||
func UpdateClassCategoryEPCost(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
respondWithError(c, http.StatusBadRequest, "Invalid ID")
|
||||
return
|
||||
}
|
||||
|
||||
var cost models.ClassCategoryEPCost
|
||||
if err := database.DB.First(&cost, uint(id)).Error; err != nil {
|
||||
respondWithError(c, http.StatusNotFound, "Class category EP cost not found")
|
||||
return
|
||||
}
|
||||
|
||||
var payload classCategoryEPCostUpdateRequest
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
respondWithError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if payload.EPPerTE != nil {
|
||||
cost.EPPerTE = *payload.EPPerTE
|
||||
}
|
||||
|
||||
if err := database.DB.Save(&cost).Error; err != nil {
|
||||
respondWithError(c, http.StatusInternalServerError, "Failed to update class category EP cost")
|
||||
return
|
||||
}
|
||||
|
||||
// Reload with preloads
|
||||
if err := database.DB.Preload("CharacterClass").Preload("SkillCategory").First(&cost, cost.ID).Error; err != nil {
|
||||
respondWithError(c, http.StatusInternalServerError, "Failed to reload updated cost")
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, cost)
|
||||
}
|
||||
|
||||
// --- Class Spell School EP Costs (EP per LE) ---
|
||||
|
||||
func GetClassSpellSchoolEPCosts(c *gin.Context) {
|
||||
var costs []models.ClassSpellSchoolEPCost
|
||||
if err := database.DB.
|
||||
Preload("CharacterClass").
|
||||
Preload("SpellSchool").
|
||||
Order("character_class ASC, spell_school ASC").
|
||||
Find(&costs).Error; err != nil {
|
||||
respondWithError(c, http.StatusInternalServerError, "Failed to retrieve class spell school EP costs")
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"costs": costs})
|
||||
}
|
||||
|
||||
type classSpellSchoolEPCostUpdateRequest struct {
|
||||
EPPerLE *int `json:"ep_per_le"`
|
||||
}
|
||||
|
||||
func UpdateClassSpellSchoolEPCost(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
respondWithError(c, http.StatusBadRequest, "Invalid ID")
|
||||
return
|
||||
}
|
||||
|
||||
var cost models.ClassSpellSchoolEPCost
|
||||
if err := database.DB.First(&cost, uint(id)).Error; err != nil {
|
||||
respondWithError(c, http.StatusNotFound, "Class spell school EP cost not found")
|
||||
return
|
||||
}
|
||||
|
||||
var payload classSpellSchoolEPCostUpdateRequest
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
respondWithError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if payload.EPPerLE != nil {
|
||||
cost.EPPerLE = *payload.EPPerLE
|
||||
}
|
||||
|
||||
if err := database.DB.Save(&cost).Error; err != nil {
|
||||
respondWithError(c, http.StatusInternalServerError, "Failed to update class spell school EP cost")
|
||||
return
|
||||
}
|
||||
|
||||
// Reload with preloads
|
||||
if err := database.DB.Preload("CharacterClass").Preload("SpellSchool").First(&cost, cost.ID).Error; err != nil {
|
||||
respondWithError(c, http.StatusInternalServerError, "Failed to reload updated cost")
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, cost)
|
||||
}
|
||||
|
||||
// --- Spell Level LE Costs ---
|
||||
|
||||
func GetSpellLevelLECosts(c *gin.Context) {
|
||||
gs := resolveGameSystemOrDefault(c)
|
||||
if gs == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var costs []models.SpellLevelLECost
|
||||
if err := database.DB.Where("game_system=? OR game_system_id=?", gs.Name, gs.ID).
|
||||
Order("level ASC").
|
||||
Find(&costs).Error; err != nil {
|
||||
respondWithError(c, http.StatusInternalServerError, "Failed to retrieve spell level LE costs")
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"costs": costs})
|
||||
}
|
||||
|
||||
type spellLevelLECostUpdateRequest struct {
|
||||
LERequired *int `json:"le_required"`
|
||||
}
|
||||
|
||||
func UpdateSpellLevelLECost(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
respondWithError(c, http.StatusBadRequest, "Invalid ID")
|
||||
return
|
||||
}
|
||||
|
||||
var cost models.SpellLevelLECost
|
||||
if err := database.DB.First(&cost, uint(id)).Error; err != nil {
|
||||
respondWithError(c, http.StatusNotFound, "Spell level LE cost not found")
|
||||
return
|
||||
}
|
||||
|
||||
var payload spellLevelLECostUpdateRequest
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
respondWithError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if payload.LERequired != nil {
|
||||
cost.LERequired = *payload.LERequired
|
||||
}
|
||||
|
||||
if err := database.DB.Save(&cost).Error; err != nil {
|
||||
respondWithError(c, http.StatusInternalServerError, "Failed to update spell level LE cost")
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, cost)
|
||||
}
|
||||
|
||||
// --- Skill Category Difficulties (Lernen) ---
|
||||
|
||||
func GetSkillCategoryDifficulties(c *gin.Context) {
|
||||
var items []models.SkillCategoryDifficulty
|
||||
if err := database.DB.
|
||||
Preload("Skill").
|
||||
Preload("SkillCategory").
|
||||
Preload("SkillDifficulty").
|
||||
Order("skill_category ASC, skill_difficulty ASC, learn_cost ASC").
|
||||
Find(&items).Error; err != nil {
|
||||
respondWithError(c, http.StatusInternalServerError, "Failed to retrieve skill category difficulties")
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
|
||||
type skillCategoryDifficultyUpdateRequest struct {
|
||||
LearnCost *int `json:"learn_cost"`
|
||||
}
|
||||
|
||||
func UpdateSkillCategoryDifficulty(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
respondWithError(c, http.StatusBadRequest, "Invalid ID")
|
||||
return
|
||||
}
|
||||
|
||||
var item models.SkillCategoryDifficulty
|
||||
if err := database.DB.First(&item, uint(id)).Error; err != nil {
|
||||
respondWithError(c, http.StatusNotFound, "Skill category difficulty not found")
|
||||
return
|
||||
}
|
||||
|
||||
var payload skillCategoryDifficultyUpdateRequest
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
respondWithError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if payload.LearnCost != nil {
|
||||
item.LearnCost = *payload.LearnCost
|
||||
}
|
||||
|
||||
if err := database.DB.Save(&item).Error; err != nil {
|
||||
respondWithError(c, http.StatusInternalServerError, "Failed to update skill category difficulty")
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.DB.Preload("Skill").Preload("SkillCategory").Preload("SkillDifficulty").First(&item, item.ID).Error; err != nil {
|
||||
respondWithError(c, http.StatusInternalServerError, "Failed to reload updated item")
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, item)
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
package maintenance
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"bamort/bmrt/models"
|
||||
"bamort/database"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// --- Lookup helpers for pre-seeded test data ---
|
||||
|
||||
func findCharacterClass(t *testing.T, code string) models.CharacterClass {
|
||||
t.Helper()
|
||||
var cc models.CharacterClass
|
||||
require.NoError(t, database.DB.Where("code = ?", code).First(&cc).Error,
|
||||
"expected pre-seeded character class %q", code)
|
||||
return cc
|
||||
}
|
||||
|
||||
func findSkillCategory(t *testing.T, name string) models.SkillCategory {
|
||||
t.Helper()
|
||||
var sc models.SkillCategory
|
||||
require.NoError(t, database.DB.Where("name = ?", name).First(&sc).Error,
|
||||
"expected pre-seeded skill category %q", name)
|
||||
return sc
|
||||
}
|
||||
|
||||
func findSkillDifficulty(t *testing.T, name string) models.SkillDifficulty {
|
||||
t.Helper()
|
||||
var sd models.SkillDifficulty
|
||||
require.NoError(t, database.DB.Where("name = ?", name).First(&sd).Error,
|
||||
"expected pre-seeded skill difficulty %q", name)
|
||||
return sd
|
||||
}
|
||||
|
||||
func findSpellSchool(t *testing.T, name string) models.SpellSchool {
|
||||
t.Helper()
|
||||
var ss models.SpellSchool
|
||||
require.NoError(t, database.DB.Where("name = ?", name).First(&ss).Error,
|
||||
"expected pre-seeded spell school %q", name)
|
||||
return ss
|
||||
}
|
||||
|
||||
func findClassCategoryEPCost(t *testing.T, classCode, categoryName string) models.ClassCategoryEPCost {
|
||||
t.Helper()
|
||||
var cost models.ClassCategoryEPCost
|
||||
require.NoError(t, database.DB.
|
||||
Where("character_class = ? AND skill_category = ?", classCode, categoryName).
|
||||
First(&cost).Error,
|
||||
"expected pre-seeded EP cost for class %q / category %q", classCode, categoryName)
|
||||
return cost
|
||||
}
|
||||
|
||||
func findClassSpellSchoolEPCost(t *testing.T, classCode, schoolName string) models.ClassSpellSchoolEPCost {
|
||||
t.Helper()
|
||||
var cost models.ClassSpellSchoolEPCost
|
||||
require.NoError(t, database.DB.
|
||||
Where("character_class = ? AND spell_school = ?", classCode, schoolName).
|
||||
First(&cost).Error,
|
||||
"expected pre-seeded EP cost for class %q / school %q", classCode, schoolName)
|
||||
return cost
|
||||
}
|
||||
|
||||
func findSpellLevelLECost(t *testing.T, level int) models.SpellLevelLECost {
|
||||
t.Helper()
|
||||
var cost models.SpellLevelLECost
|
||||
require.NoError(t, database.DB.Where("level = ?", level).First(&cost).Error,
|
||||
"expected pre-seeded spell level LE cost for level %d", level)
|
||||
return cost
|
||||
}
|
||||
|
||||
// --- Character Classes ---
|
||||
|
||||
func TestGetCharacterClasses(t *testing.T) {
|
||||
token, router, _ := setupMaintenanceTest(t)
|
||||
seeded := findCharacterClass(t, "Hx")
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, "/api/maintenance/character-classes", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
var payload struct {
|
||||
CharacterClasses []models.CharacterClass `json:"character_classes"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload))
|
||||
assert.GreaterOrEqual(t, len(payload.CharacterClasses), 15, "expected at least 15 pre-seeded classes")
|
||||
|
||||
var found bool
|
||||
for _, cc := range payload.CharacterClasses {
|
||||
if cc.ID == seeded.ID {
|
||||
found = true
|
||||
assert.Equal(t, "Hx", cc.Code)
|
||||
assert.Equal(t, "Hexer", cc.Name)
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "expected Hexer (Hx) in response")
|
||||
}
|
||||
|
||||
func TestGetCharacterClassesUnauthorized(t *testing.T) {
|
||||
_, router, _ := setupMaintenanceTest(t)
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, "/api/maintenance/character-classes", nil)
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.Code)
|
||||
}
|
||||
|
||||
// --- Skill Categories ---
|
||||
|
||||
func TestGetSkillCategories(t *testing.T) {
|
||||
token, router, _ := setupMaintenanceTest(t)
|
||||
seeded := findSkillCategory(t, "Alltag")
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, "/api/maintenance/skill-categories", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
var payload struct {
|
||||
SkillCategories []models.SkillCategory `json:"skill_categories"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload))
|
||||
assert.GreaterOrEqual(t, len(payload.SkillCategories), 9, "expected at least 9 pre-seeded skill categories")
|
||||
|
||||
var found bool
|
||||
for _, sc := range payload.SkillCategories {
|
||||
if sc.ID == seeded.ID {
|
||||
found = true
|
||||
assert.Equal(t, "Alltag", sc.Name)
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "expected Alltag in response")
|
||||
}
|
||||
|
||||
// --- Skill Difficulties ---
|
||||
|
||||
func TestGetSkillDifficulties(t *testing.T) {
|
||||
token, router, _ := setupMaintenanceTest(t)
|
||||
seeded := findSkillDifficulty(t, "leicht")
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, "/api/maintenance/skill-difficulties", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
var payload struct {
|
||||
SkillDifficulties []models.SkillDifficulty `json:"skill_difficulties"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload))
|
||||
assert.GreaterOrEqual(t, len(payload.SkillDifficulties), 4, "expected at least 4 pre-seeded difficulties")
|
||||
|
||||
var found bool
|
||||
for _, sd := range payload.SkillDifficulties {
|
||||
if sd.ID == seeded.ID {
|
||||
found = true
|
||||
assert.Equal(t, "leicht", sd.Name)
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "expected leicht in response")
|
||||
}
|
||||
|
||||
// --- Spell Schools ---
|
||||
|
||||
func TestGetSpellSchools(t *testing.T) {
|
||||
token, router, _ := setupMaintenanceTest(t)
|
||||
seeded := findSpellSchool(t, "Beherrschen")
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, "/api/maintenance/spell-schools", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
var payload struct {
|
||||
SpellSchools []models.SpellSchool `json:"spell_schools"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload))
|
||||
assert.GreaterOrEqual(t, len(payload.SpellSchools), 10, "expected at least 10 pre-seeded spell schools")
|
||||
|
||||
var found bool
|
||||
for _, ss := range payload.SpellSchools {
|
||||
if ss.ID == seeded.ID {
|
||||
found = true
|
||||
assert.Equal(t, "Beherrschen", ss.Name)
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "expected Beherrschen in response")
|
||||
}
|
||||
|
||||
// --- Class Category EP Costs (EP per TE) ---
|
||||
|
||||
func TestGetClassCategoryEPCosts(t *testing.T) {
|
||||
token, router, _ := setupMaintenanceTest(t)
|
||||
// Krieger / Kampf = 10 EP per TE (pre-seeded)
|
||||
seeded := findClassCategoryEPCost(t, "Kr", "Kampf")
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, "/api/maintenance/class-category-ep-costs", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
var payload struct {
|
||||
Costs []models.ClassCategoryEPCost `json:"costs"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload))
|
||||
assert.GreaterOrEqual(t, len(payload.Costs), 135, "expected at least 135 pre-seeded class/category EP costs")
|
||||
|
||||
var found bool
|
||||
for _, c := range payload.Costs {
|
||||
if c.ID == seeded.ID {
|
||||
found = true
|
||||
assert.Equal(t, 10, c.EPPerTE)
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "expected Krieger/Kampf EP cost in response")
|
||||
}
|
||||
|
||||
func TestUpdateClassCategoryEPCost(t *testing.T) {
|
||||
token, router, _ := setupMaintenanceTest(t)
|
||||
// Magier / Wissen = 10 EP per TE (pre-seeded)
|
||||
seeded := findClassCategoryEPCost(t, "Ma", "Wissen")
|
||||
originalEP := seeded.EPPerTE
|
||||
|
||||
body := map[string]interface{}{"ep_per_te": 99}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPut, fmt.Sprintf("/api/maintenance/class-category-ep-costs/%d", seeded.ID), bytes.NewBuffer(bodyBytes))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
var updated models.ClassCategoryEPCost
|
||||
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &updated))
|
||||
assert.Equal(t, seeded.ID, updated.ID)
|
||||
assert.Equal(t, 99, updated.EPPerTE)
|
||||
assert.NotEqual(t, originalEP, updated.EPPerTE, "EP value should have changed")
|
||||
}
|
||||
|
||||
func TestUpdateClassCategoryEPCostNotFound(t *testing.T) {
|
||||
token, router, _ := setupMaintenanceTest(t)
|
||||
|
||||
body := map[string]interface{}{"ep_per_te": 30}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPut, "/api/maintenance/class-category-ep-costs/99999", bytes.NewBuffer(bodyBytes))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, resp.Code)
|
||||
}
|
||||
|
||||
// --- Class Spell School EP Costs (EP per LE) ---
|
||||
|
||||
func TestGetClassSpellSchoolEPCosts(t *testing.T) {
|
||||
token, router, _ := setupMaintenanceTest(t)
|
||||
// Druide / Erkennen = 120 EP per LE (pre-seeded)
|
||||
seeded := findClassSpellSchoolEPCost(t, "Dr", "Erkennen")
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, "/api/maintenance/class-spell-school-ep-costs", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
var payload struct {
|
||||
Costs []models.ClassSpellSchoolEPCost `json:"costs"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload))
|
||||
assert.Greater(t, len(payload.Costs), 0, "expected pre-seeded class/spell school EP costs")
|
||||
|
||||
var found bool
|
||||
for _, c := range payload.Costs {
|
||||
if c.ID == seeded.ID {
|
||||
found = true
|
||||
assert.Equal(t, 120, c.EPPerLE)
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "expected Druide/Erkennen EP cost in response")
|
||||
}
|
||||
|
||||
func TestUpdateClassSpellSchoolEPCost(t *testing.T) {
|
||||
token, router, _ := setupMaintenanceTest(t)
|
||||
// Hexer / Bewegen = 90 EP per LE (pre-seeded)
|
||||
seeded := findClassSpellSchoolEPCost(t, "Hx", "Bewegen")
|
||||
originalEP := seeded.EPPerLE
|
||||
|
||||
body := map[string]interface{}{"ep_per_le": 77}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPut, fmt.Sprintf("/api/maintenance/class-spell-school-ep-costs/%d", seeded.ID), bytes.NewBuffer(bodyBytes))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
var updated models.ClassSpellSchoolEPCost
|
||||
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &updated))
|
||||
assert.Equal(t, seeded.ID, updated.ID)
|
||||
assert.Equal(t, 77, updated.EPPerLE)
|
||||
assert.NotEqual(t, originalEP, updated.EPPerLE, "EP value should have changed")
|
||||
}
|
||||
|
||||
func TestUpdateClassSpellSchoolEPCostNotFound(t *testing.T) {
|
||||
token, router, _ := setupMaintenanceTest(t)
|
||||
|
||||
body := map[string]interface{}{"ep_per_le": 60}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPut, "/api/maintenance/class-spell-school-ep-costs/99999", bytes.NewBuffer(bodyBytes))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, resp.Code)
|
||||
}
|
||||
|
||||
// --- Spell Level LE Costs ---
|
||||
|
||||
func TestGetSpellLevelLECosts(t *testing.T) {
|
||||
token, router, _ := setupMaintenanceTest(t)
|
||||
// Level 3 = 2 LE required (pre-seeded)
|
||||
seeded := findSpellLevelLECost(t, 3)
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, "/api/maintenance/spell-level-le-costs", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
var payload struct {
|
||||
Costs []models.SpellLevelLECost `json:"costs"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload))
|
||||
assert.GreaterOrEqual(t, len(payload.Costs), 12, "expected 12 pre-seeded spell level LE costs")
|
||||
|
||||
var found bool
|
||||
for _, c := range payload.Costs {
|
||||
if c.ID == seeded.ID {
|
||||
found = true
|
||||
assert.Equal(t, 3, c.Level)
|
||||
assert.Equal(t, 2, c.LERequired)
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "expected level 3 spell LE cost in response")
|
||||
}
|
||||
|
||||
func TestUpdateSpellLevelLECost(t *testing.T) {
|
||||
token, router, _ := setupMaintenanceTest(t)
|
||||
// Level 5 = 5 LE required (pre-seeded)
|
||||
seeded := findSpellLevelLECost(t, 5)
|
||||
originalLE := seeded.LERequired
|
||||
|
||||
body := map[string]interface{}{"le_required": 42}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPut, fmt.Sprintf("/api/maintenance/spell-level-le-costs/%d", seeded.ID), bytes.NewBuffer(bodyBytes))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
var updated models.SpellLevelLECost
|
||||
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &updated))
|
||||
assert.Equal(t, seeded.ID, updated.ID)
|
||||
assert.Equal(t, 42, updated.LERequired)
|
||||
assert.NotEqual(t, originalLE, updated.LERequired, "LE value should have changed")
|
||||
}
|
||||
|
||||
func TestUpdateSpellLevelLECostNotFound(t *testing.T) {
|
||||
token, router, _ := setupMaintenanceTest(t)
|
||||
|
||||
body := map[string]interface{}{"le_required": 6}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPut, "/api/maintenance/spell-level-le-costs/99999", bytes.NewBuffer(bodyBytes))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, resp.Code)
|
||||
}
|
||||
|
||||
// --- Skill Category Difficulties ---
|
||||
|
||||
func TestGetSkillCategoryDifficulties(t *testing.T) {
|
||||
token, router, _ := setupMaintenanceTest(t)
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, "/api/maintenance/skill-category-difficulties", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
var payload struct {
|
||||
Items []models.SkillCategoryDifficulty `json:"items"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload))
|
||||
assert.GreaterOrEqual(t, len(payload.Items), 10, "expected pre-seeded skill category difficulties")
|
||||
|
||||
// Verify preloads are populated: find Bootfahren/Alltag/leicht (learn_cost=1)
|
||||
var found bool
|
||||
for _, item := range payload.Items {
|
||||
if item.Skill.Name == "Bootfahren" && item.SkillCategory.Name == "Alltag" {
|
||||
found = true
|
||||
assert.Equal(t, "leicht", item.SkillDifficulty.Name)
|
||||
assert.Equal(t, 1, item.LearnCost)
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "expected Bootfahren/Alltag/leicht in response")
|
||||
}
|
||||
|
||||
func TestGetSkillCategoryDifficultiesUnauthorized(t *testing.T) {
|
||||
_, router, _ := setupMaintenanceTest(t)
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, "/api/maintenance/skill-category-difficulties", nil)
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.Code)
|
||||
}
|
||||
|
||||
func TestUpdateSkillCategoryDifficulty(t *testing.T) {
|
||||
token, router, _ := setupMaintenanceTest(t)
|
||||
|
||||
// Find Bootfahren in Alltag/leicht (learn_cost=1 in test DB)
|
||||
var item models.SkillCategoryDifficulty
|
||||
require.NoError(t, database.DB.
|
||||
Joins("Skill").Joins("SkillCategory").Joins("SkillDifficulty").
|
||||
Where("Skill__name = ? AND SkillCategory__name = ? AND SkillDifficulty__name = ?",
|
||||
"Bootfahren", "Alltag", "leicht").
|
||||
First(&item).Error,
|
||||
"expected pre-seeded Bootfahren/Alltag/leicht entry")
|
||||
originalLC := item.LearnCost
|
||||
|
||||
body := map[string]interface{}{"learn_cost": 3}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPut,
|
||||
fmt.Sprintf("/api/maintenance/skill-category-difficulties/%d", item.ID),
|
||||
bytes.NewBuffer(bodyBytes))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
var updated models.SkillCategoryDifficulty
|
||||
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &updated))
|
||||
assert.Equal(t, item.ID, updated.ID)
|
||||
assert.Equal(t, 3, updated.LearnCost)
|
||||
assert.NotEqual(t, originalLC, updated.LearnCost)
|
||||
}
|
||||
|
||||
func TestUpdateSkillCategoryDifficultyNotFound(t *testing.T) {
|
||||
token, router, _ := setupMaintenanceTest(t)
|
||||
|
||||
body := map[string]interface{}{"learn_cost": 5}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPut, "/api/maintenance/skill-category-difficulties/99999",
|
||||
bytes.NewBuffer(bodyBytes))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, resp.Code)
|
||||
}
|
||||
@@ -20,6 +20,18 @@ func RegisterRoutes(r *gin.RouterGroup) {
|
||||
charGrp.PUT("/gsm-misc/:id", UpdateMisc)
|
||||
charGrp.GET("/skill-improvement-cost2", GetSkillImprovementCost2)
|
||||
charGrp.PUT("/skill-improvement-cost2/:id", UpdateSkillImprovementCost2)
|
||||
charGrp.GET("/character-classes", GetCharacterClasses)
|
||||
charGrp.GET("/skill-categories", GetSkillCategories)
|
||||
charGrp.GET("/skill-difficulties", GetSkillDifficulties)
|
||||
charGrp.GET("/spell-schools", GetSpellSchools)
|
||||
charGrp.GET("/class-category-ep-costs", GetClassCategoryEPCosts)
|
||||
charGrp.PUT("/class-category-ep-costs/:id", UpdateClassCategoryEPCost)
|
||||
charGrp.GET("/class-spell-school-ep-costs", GetClassSpellSchoolEPCosts)
|
||||
charGrp.PUT("/class-spell-school-ep-costs/:id", UpdateClassSpellSchoolEPCost)
|
||||
charGrp.GET("/spell-level-le-costs", GetSpellLevelLECosts)
|
||||
charGrp.PUT("/spell-level-le-costs/:id", UpdateSpellLevelLECost)
|
||||
charGrp.GET("/skill-category-difficulties", GetSkillCategoryDifficulties)
|
||||
charGrp.PUT("/skill-category-difficulties/:id", UpdateSkillCategoryDifficulty)
|
||||
charGrp.GET("/setupcheck", SetupCheck)
|
||||
charGrp.GET("/setupcheck-dev", SetupCheckDev)
|
||||
charGrp.GET("/mktestdata", MakeTestdataFromLive)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# EP-Kosten für 1 Trainingseinheit (TE)
|
||||
|
||||
1 Lerneinheit (LE) kostet das Dreifache an EP (+ 6 EP für Elfen)
|
||||
|
||||
| Klasse | Alltag | Freil. | Halbw. | Kampf | Körper | Sozial | Unterw. | Waffen | Wissen |
|
||||
|--------|--------|--------|--------|-------|--------|--------|---------|--------|--------|
|
||||
| As | 20 | 20 | 20 | 30 | 10 | 20 | 10 | 20 | 20 |
|
||||
| Bb | 20 | 10 | 30 | 10 | 10 | 30 | 30 | 20 | 40 |
|
||||
| Gl | 20 | 30 | 10 | 20 | 30 | 10 | 30 | 20 | 20 |
|
||||
| Hä | 10 | 20 | 20 | 20 | 20 | 10 | 40 | 20 | 20 |
|
||||
| Kr | 20 | 30 | 30 | 10 | 20 | 20 | 30 | 10 | 40 |
|
||||
| Sp | 20 | 30 | 10 | 40 | 10 | 10 | 10 | 20 | 30 |
|
||||
| Wa | 20 | 10 | 20 | 20 | 10 | 30 | 30 | 20 | 20 |
|
||||
| Ba | 20 | 20 | 20 | 30 | 20 | 10 | 40 | 20 | 20 |
|
||||
| Or | 20 | 20 | 40 | 20 | 10 | 20 | 40 | 20 | 30 |
|
||||
| Dr | 20 | 10 | 40 | 40 | 20 | 30 | 40 | 40 | 10 |
|
||||
| Hx | 20 | 20 | 30 | 40 | 30 | 20 | 30 | 40 | 20 |
|
||||
| Ma | 20 | 30 | 40 | 40 | 30 | 30 | 40 | 40 | 10 |
|
||||
| PB | 20 | 30 | 30 | 40 | 30 | 10 | 40 | 40 | 20 |
|
||||
| PS | 20 | 30 | 40 | 30 | 30 | 30 | 40 | 30 | 20 |
|
||||
| Sc | 20 | 10 | 40 | 40 | 20 | 20 | 40 | 40 | 20 |
|
||||
|
||||
Geldkosten: 20 GS je TE, 200 GS je LE
|
||||
|
||||
# EP-Kosten für 1 Lerneinheit (LE) für Zauber (+ 6 EP für Elfen).
|
||||
|
||||
| Klasse | Beherr. | Beweg. | Erkenn. | Erschaff. | Formen | Veränd. | Zerstören | Wunder | Dweom. | Lied |
|
||||
|--------|---------|--------|---------|-----------|--------|---------|-----------|--------|---------|------|
|
||||
| Dr | 90 | 60 | 120 | 90 | 60 | 90 | 120 | - | 30 | - |
|
||||
| Hx | 30 | 90 | 90 | 90 | 60 | 30 | 60 | - | 90 | - |
|
||||
| Ma* | 60 | 60 | 60 | 60 | 60 | 60 | 60 | - | - | - |
|
||||
| PB | 60 | 90 | 60 | 90 | 120 | 120 | 90 | 30 | - | - |
|
||||
| PS | 90 | 90 | 90 | 60 | 120 | 120 | 60 | 30 | - | - |
|
||||
| Sc | 60 | 90 | 60 | 120 | 120 | 60 | 60 | 60 | 60 | - |
|
||||
| Ba | - | - | - | - | - | - | - | - | - | 30 |
|
||||
| Or | - | - | - | - | - | - | - | 30 | - | - |
|
||||
|
||||
Geldkosten: 100 GS je LE
|
||||
Lernen von Spruchrollen: 1/3 EP je LE bei Erfolg, pauschal 20 GS je Lernversuch
|
||||
*: Ma erhalten LE für Sprüche aus ihrem Spezialgebiet für 30 EP
|
||||
|
||||
# Lern- und Trainingslisten
|
||||
|
||||
Die Zahl an Lern- und Trainingseinheiten für ein und dieselbe Fertigkeit stimmen in allen Gruppen überein. Das Steigern von Reiten+14 auf Reiten+15 kostet zum Beispiel 5 TE unabhängig davon, ob man Reiten als Alltagsoder als Kampffertigkeit verbessert. Für das Lernen von Gassenwissen braucht man immer 2 LE, gleich ob man es als Halbwelt-, Unterwelt- oder soziale Fertigkeit erwirbt.
|
||||
Die Gruppenzugehörigkeit entscheidet nur darüber, wie viele EP der
|
||||
Abenteurer abhängig von seinem Typ für LE und TE bezahlen muss.
|
||||
|
||||
|
||||
## Alltag
|
||||
|
||||
### Lernen
|
||||
|
||||
| Schwierigkeit | LE | Fertigkeiten |
|
||||
|---------------|----|--------------|
|
||||
| leicht | 1 LE | Bootfahren+12 (Gs), Glücksspiel+12 (Gs), Klettern+12 (St), Musizieren+12 (Gs), Reiten+12 (Gw), Schwimmen+12 (Gw), Seilkunst+12 (Gs), Wagenlenken+12 (Gs) |
|
||||
| normal | 1 LE | Schreiben+8 (In), Sprache+8 (In) |
|
||||
| schwer | 2 LE | Erste Hilfe+8 (Gs), Etikette+8 (In) |
|
||||
| sehr schwer | 10 LE | Gerätekunde+8 (In), Geschäftssinn+8 (In) |
|
||||
|
||||
### Verbessern (TE)
|
||||
|
||||
| Schwierigkeit | +9 | +10 | +11 | +12 | +13 | +14 | +15 | +16 | +17 | +18 |
|
||||
|---------------|----|----|----|----|----|----|----|----|----|----|
|
||||
| leicht | - | - | - | - | 1 | 2 | 5 | 10 | 10 | 20 |
|
||||
| normal | 1 | 1 | 1 | 1 | 2 | 2 | 5 | 10 | 10 | 20 |
|
||||
| schwer | 2 | 2 | 5 | 5 | 10 | 10 | 20 | 20 | 50 | 50 |
|
||||
| s. schwer | 5 | 5 | 10 | 10 | 20 | 20 | 50 | 50 | 100 | 100 |
|
||||
@@ -39,6 +39,7 @@ import GameSystemView from "./maintenance/GameSystemView.vue";
|
||||
import LitSourceView from "./maintenance/LitSourceView.vue";
|
||||
import MiscLookupView from "./maintenance/MiscLookupView.vue";
|
||||
import SkillImprovementCostView from "./maintenance/SkillImprovementCostView.vue";
|
||||
import LearningCostView from "./maintenance/LearningCostView.vue";
|
||||
|
||||
|
||||
export default {
|
||||
@@ -55,6 +56,7 @@ export default {
|
||||
LitSourceView,
|
||||
MiscLookupView,
|
||||
SkillImprovementCostView,
|
||||
LearningCostView,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -82,7 +84,7 @@ export default {
|
||||
{ id: 7, name: "litsource", component: "LitSourceView" },
|
||||
{ id: 8, name: "misc", component: "MiscLookupView" },
|
||||
{ id: 9, name: "skillimprovement", component: "SkillImprovementCostView" },
|
||||
|
||||
{ id: 10, name: "learningcost", component: "LearningCostView" },
|
||||
],
|
||||
};
|
||||
},
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
<template>
|
||||
<div class="header-section">
|
||||
<h2>{{ $t('maintenance') }} - {{ $t('learningcost.title') }}</h2>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-box">{{ error }}</div>
|
||||
|
||||
<div v-if="isLoading" class="cd-view">
|
||||
<p>{{ $t('common.loading') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Section 1: EP per TE — Class × Skill Category matrix -->
|
||||
<div v-if="!isLoading" class="cd-view">
|
||||
<h3>{{ $t('learningcost.headerEPPerTE') }}</h3>
|
||||
<p class="lc-description">{{ $t('learningcost.epPerTEDesc') }}</p>
|
||||
<div class="cd-list">
|
||||
<table class="cd-table lc-matrix">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="cd-table-header lc-sticky-col">{{ $t('learningcost.class') }}</th>
|
||||
<th v-for="cat in skillCategories" :key="cat.id" class="cd-table-header">
|
||||
{{ cat.name }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="cc in characterClasses" :key="cc.code">
|
||||
<td class="lc-sticky-col lc-row-header">{{ cc.code }}</td>
|
||||
<td
|
||||
v-for="cat in skillCategories"
|
||||
:key="cat.id"
|
||||
class="lc-cell"
|
||||
@click="startEditEPPerTE(cc.code, cat.name)"
|
||||
>
|
||||
<template v-if="editingCell === cellKey('te', cc.code, cat.name)">
|
||||
<input
|
||||
v-model.number="editValue"
|
||||
type="number"
|
||||
min="0"
|
||||
class="lc-input"
|
||||
@keyup.enter="saveEditEPPerTE(cc.code, cat.name)"
|
||||
@keyup.escape="cancelEdit"
|
||||
@blur="saveEditEPPerTE(cc.code, cat.name)"
|
||||
ref="cellInput"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ getEPPerTE(cc.code, cat.name) ?? '-' }}
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="lc-note">{{ $t('learningcost.goldCostTE') }}</p>
|
||||
<p class="lc-hint">{{ $t('learningcost.clickToEdit') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Section 2: EP per LE — Class × Spell School matrix -->
|
||||
<div v-if="!isLoading" class="cd-view">
|
||||
<h3>{{ $t('learningcost.headerEPPerLE') }}</h3>
|
||||
<p class="lc-description">{{ $t('learningcost.epPerLEDesc') }}</p>
|
||||
<div class="cd-list">
|
||||
<table class="cd-table lc-matrix">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="cd-table-header lc-sticky-col">{{ $t('learningcost.class') }}</th>
|
||||
<th v-for="school in spellSchools" :key="school.id" class="cd-table-header">
|
||||
{{ school.name }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="cc in spellCasterClasses" :key="cc.code">
|
||||
<td class="lc-sticky-col lc-row-header">{{ cc.code }}</td>
|
||||
<td
|
||||
v-for="school in spellSchools"
|
||||
:key="school.id"
|
||||
class="lc-cell"
|
||||
@click="startEditEPPerLE(cc.code, school.name)"
|
||||
>
|
||||
<template v-if="editingCell === cellKey('le', cc.code, school.name)">
|
||||
<input
|
||||
v-model.number="editValue"
|
||||
type="number"
|
||||
min="0"
|
||||
class="lc-input"
|
||||
@keyup.enter="saveEditEPPerLE(cc.code, school.name)"
|
||||
@keyup.escape="cancelEdit"
|
||||
@blur="saveEditEPPerLE(cc.code, school.name)"
|
||||
ref="cellInput"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ getEPPerLE(cc.code, school.name) ?? '-' }}
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="lc-note">{{ $t('learningcost.goldCostLE') }}</p>
|
||||
<p class="lc-note">{{ $t('learningcost.maSpecialNote') }}</p>
|
||||
<p class="lc-note">{{ $t('learningcost.scrollLearnNote') }}</p>
|
||||
<p class="lc-hint">{{ $t('learningcost.clickToEdit') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Section 3: LE per Spell Level -->
|
||||
<div v-if="!isLoading" class="cd-view">
|
||||
<h3>{{ $t('learningcost.headerSpellLevelLE') }}</h3>
|
||||
<p class="lc-description">{{ $t('learningcost.spellLevelLEDesc') }}</p>
|
||||
<div class="cd-list">
|
||||
<table class="cd-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="cd-table-header">{{ $t('learningcost.spellLevel') }}</th>
|
||||
<th class="cd-table-header">{{ $t('learningcost.leRequired') }}</th>
|
||||
<th class="cd-table-header"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="cost in spellLevelCosts" :key="cost.id">
|
||||
<tr v-if="editingSpellLevelId !== cost.id">
|
||||
<td>{{ cost.level }}</td>
|
||||
<td>{{ cost.le_required }}</td>
|
||||
<td><button @click="startEditSpellLevel(cost)">{{ $t('common.edit') }}</button></td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td>{{ cost.level }}</td>
|
||||
<td>
|
||||
<input
|
||||
v-model.number="editSpellLevelValue"
|
||||
type="number"
|
||||
min="0"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div class="edit-actions">
|
||||
<button class="btn-primary btn-save" :disabled="isSaving" @click="saveEditSpellLevel">
|
||||
<span v-if="!isSaving">{{ $t('common.save') }}</span>
|
||||
<span v-else>{{ $t('common.saving') }}</span>
|
||||
</button>
|
||||
<button class="btn-cancel" :disabled="isSaving" @click="cancelEditSpellLevel">
|
||||
{{ $t('common.cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lc-description {
|
||||
margin: 8px 0;
|
||||
font-style: italic;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.lc-note {
|
||||
margin: 4px 0;
|
||||
font-size: 0.9em;
|
||||
font-style: italic;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.lc-hint {
|
||||
margin-top: 6px;
|
||||
font-size: 0.85em;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.lc-matrix {
|
||||
min-width: max-content;
|
||||
}
|
||||
.lc-matrix th,
|
||||
.lc-matrix td {
|
||||
text-align: center;
|
||||
min-width: 60px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lc-sticky-col {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
background: var(--color-background);
|
||||
z-index: 1;
|
||||
}
|
||||
.lc-row-header {
|
||||
text-align: left;
|
||||
font-weight: bold;
|
||||
padding-right: 12px;
|
||||
}
|
||||
.lc-cell {
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.lc-cell:hover {
|
||||
background: var(--color-background-mute);
|
||||
}
|
||||
.lc-input {
|
||||
width: 60px;
|
||||
text-align: center;
|
||||
padding: 2px 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.error-box {
|
||||
margin: 10px 0;
|
||||
padding: 10px 12px;
|
||||
background: #ffe3e3;
|
||||
color: #8a1c1c;
|
||||
border: 1px solid #f5c2c2;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.edit-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import API from '../../utils/api'
|
||||
|
||||
export default {
|
||||
name: 'LearningCostView',
|
||||
data() {
|
||||
return {
|
||||
characterClasses: [],
|
||||
skillCategories: [],
|
||||
spellSchools: [],
|
||||
epPerTECosts: [],
|
||||
epPerLECosts: [],
|
||||
spellLevelCosts: [],
|
||||
isLoading: false,
|
||||
isSaving: false,
|
||||
error: '',
|
||||
// Matrix cell editing
|
||||
editingCell: null,
|
||||
editValue: 0,
|
||||
// Spell level editing
|
||||
editingSpellLevelId: null,
|
||||
editSpellLevelValue: 0,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
spellCasterClasses() {
|
||||
const codesWithSpells = new Set(this.epPerLECosts.map(c => c.characterClass || c.character_class?.code))
|
||||
return this.characterClasses.filter(cc => codesWithSpells.has(cc.code))
|
||||
},
|
||||
},
|
||||
async created() {
|
||||
await this.loadAll()
|
||||
},
|
||||
methods: {
|
||||
cellKey(type, classCode, colName) {
|
||||
return `${type}:${classCode}:${colName}`
|
||||
},
|
||||
|
||||
// --- Data loading ---
|
||||
|
||||
async loadAll() {
|
||||
this.isLoading = true
|
||||
this.error = ''
|
||||
try {
|
||||
const [classesResp, categoriesResp, schoolsResp, epTEResp, epLEResp, spellLevelResp] = await Promise.all([
|
||||
API.get('/api/maintenance/character-classes'),
|
||||
API.get('/api/maintenance/skill-categories'),
|
||||
API.get('/api/maintenance/spell-schools'),
|
||||
API.get('/api/maintenance/class-category-ep-costs'),
|
||||
API.get('/api/maintenance/class-spell-school-ep-costs'),
|
||||
API.get('/api/maintenance/spell-level-le-costs'),
|
||||
])
|
||||
this.characterClasses = classesResp.data?.character_classes || []
|
||||
this.skillCategories = categoriesResp.data?.skill_categories || []
|
||||
this.spellSchools = schoolsResp.data?.spell_schools || []
|
||||
this.epPerTECosts = epTEResp.data?.costs || []
|
||||
this.epPerLECosts = epLEResp.data?.costs || []
|
||||
this.spellLevelCosts = spellLevelResp.data?.costs || []
|
||||
} catch (err) {
|
||||
console.error('Failed to load learning costs:', err)
|
||||
this.error = err.response?.data?.error || err.message
|
||||
} finally {
|
||||
this.isLoading = false
|
||||
}
|
||||
},
|
||||
|
||||
// --- EP per TE matrix ---
|
||||
|
||||
getEPPerTE(classCode, categoryName) {
|
||||
const entry = this.epPerTECosts.find(
|
||||
c => (c.characterClass === classCode || c.character_class?.code === classCode) &&
|
||||
(c.skillCategory === categoryName || c.skill_category?.name === categoryName)
|
||||
)
|
||||
return entry?.ep_per_te
|
||||
},
|
||||
|
||||
findEPPerTEEntry(classCode, categoryName) {
|
||||
return this.epPerTECosts.find(
|
||||
c => (c.characterClass === classCode || c.character_class?.code === classCode) &&
|
||||
(c.skillCategory === categoryName || c.skill_category?.name === categoryName)
|
||||
)
|
||||
},
|
||||
|
||||
startEditEPPerTE(classCode, categoryName) {
|
||||
const entry = this.findEPPerTEEntry(classCode, categoryName)
|
||||
if (!entry) return
|
||||
this.cancelEdit()
|
||||
this.editingCell = this.cellKey('te', classCode, categoryName)
|
||||
this.editValue = entry.ep_per_te
|
||||
this.$nextTick(() => {
|
||||
const inputs = this.$refs.cellInput
|
||||
if (inputs) {
|
||||
const el = Array.isArray(inputs) ? inputs[0] : inputs
|
||||
el?.focus()
|
||||
el?.select()
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
async saveEditEPPerTE(classCode, categoryName) {
|
||||
const entry = this.findEPPerTEEntry(classCode, categoryName)
|
||||
if (!entry || this.editingCell !== this.cellKey('te', classCode, categoryName)) return
|
||||
if (entry.ep_per_te === this.editValue) {
|
||||
this.cancelEdit()
|
||||
return
|
||||
}
|
||||
this.isSaving = true
|
||||
try {
|
||||
const resp = await API.put(`/api/maintenance/class-category-ep-costs/${entry.id}`, {
|
||||
ep_per_te: this.editValue,
|
||||
})
|
||||
const idx = this.epPerTECosts.findIndex(c => c.id === entry.id)
|
||||
if (idx !== -1) this.epPerTECosts.splice(idx, 1, resp.data)
|
||||
this.cancelEdit()
|
||||
} catch (err) {
|
||||
console.error('Failed to save EP/TE cost:', err)
|
||||
this.error = err.response?.data?.error || err.message
|
||||
} finally {
|
||||
this.isSaving = false
|
||||
}
|
||||
},
|
||||
|
||||
// --- EP per LE matrix ---
|
||||
|
||||
getEPPerLE(classCode, schoolName) {
|
||||
const entry = this.epPerLECosts.find(
|
||||
c => (c.characterClass === classCode || c.character_class?.code === classCode) &&
|
||||
(c.spellSchool === schoolName || c.spell_school?.name === schoolName)
|
||||
)
|
||||
return entry?.ep_per_le
|
||||
},
|
||||
|
||||
findEPPerLEEntry(classCode, schoolName) {
|
||||
return this.epPerLECosts.find(
|
||||
c => (c.characterClass === classCode || c.character_class?.code === classCode) &&
|
||||
(c.spellSchool === schoolName || c.spell_school?.name === schoolName)
|
||||
)
|
||||
},
|
||||
|
||||
startEditEPPerLE(classCode, schoolName) {
|
||||
const entry = this.findEPPerLEEntry(classCode, schoolName)
|
||||
if (!entry) return
|
||||
this.cancelEdit()
|
||||
this.editingCell = this.cellKey('le', classCode, schoolName)
|
||||
this.editValue = entry.ep_per_le
|
||||
this.$nextTick(() => {
|
||||
const inputs = this.$refs.cellInput
|
||||
if (inputs) {
|
||||
const el = Array.isArray(inputs) ? inputs[0] : inputs
|
||||
el?.focus()
|
||||
el?.select()
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
async saveEditEPPerLE(classCode, schoolName) {
|
||||
const entry = this.findEPPerLEEntry(classCode, schoolName)
|
||||
if (!entry || this.editingCell !== this.cellKey('le', classCode, schoolName)) return
|
||||
if (entry.ep_per_le === this.editValue) {
|
||||
this.cancelEdit()
|
||||
return
|
||||
}
|
||||
this.isSaving = true
|
||||
try {
|
||||
const resp = await API.put(`/api/maintenance/class-spell-school-ep-costs/${entry.id}`, {
|
||||
ep_per_le: this.editValue,
|
||||
})
|
||||
const idx = this.epPerLECosts.findIndex(c => c.id === entry.id)
|
||||
if (idx !== -1) this.epPerLECosts.splice(idx, 1, resp.data)
|
||||
this.cancelEdit()
|
||||
} catch (err) {
|
||||
console.error('Failed to save EP/LE cost:', err)
|
||||
this.error = err.response?.data?.error || err.message
|
||||
} finally {
|
||||
this.isSaving = false
|
||||
}
|
||||
},
|
||||
|
||||
// --- Spell Level LE costs ---
|
||||
|
||||
startEditSpellLevel(cost) {
|
||||
this.cancelEdit()
|
||||
this.editingSpellLevelId = cost.id
|
||||
this.editSpellLevelValue = cost.le_required
|
||||
},
|
||||
|
||||
cancelEditSpellLevel() {
|
||||
this.editingSpellLevelId = null
|
||||
this.editSpellLevelValue = 0
|
||||
},
|
||||
|
||||
async saveEditSpellLevel() {
|
||||
if (this.editingSpellLevelId == null) return
|
||||
this.isSaving = true
|
||||
try {
|
||||
const resp = await API.put(`/api/maintenance/spell-level-le-costs/${this.editingSpellLevelId}`, {
|
||||
le_required: this.editSpellLevelValue,
|
||||
})
|
||||
const idx = this.spellLevelCosts.findIndex(c => c.id === this.editingSpellLevelId)
|
||||
if (idx !== -1) this.spellLevelCosts.splice(idx, 1, resp.data)
|
||||
this.cancelEditSpellLevel()
|
||||
} catch (err) {
|
||||
console.error('Failed to save spell level LE cost:', err)
|
||||
this.error = err.response?.data?.error || err.message
|
||||
} finally {
|
||||
this.isSaving = false
|
||||
}
|
||||
},
|
||||
|
||||
// --- Shared ---
|
||||
|
||||
cancelEdit() {
|
||||
this.editingCell = null
|
||||
this.editValue = 0
|
||||
this.editingSpellLevelId = null
|
||||
this.editSpellLevelValue = 0
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -1,117 +1,200 @@
|
||||
<template>
|
||||
<div class="header-section">
|
||||
<h2>{{ $t('maintenance') }} - {{ $t('skillimprovement.title') }}</h2>
|
||||
<button class="btn-primary" @click="startCreate">{{ $t('newEntry') }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-box">{{ error }}</div>
|
||||
|
||||
<div class="cd-view">
|
||||
<div class="cd-list">
|
||||
<table class="cd-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="cd-table-header">{{ $t('skillimprovement.id') }}</th>
|
||||
<th class="cd-table-header">{{ $t('skillimprovement.level') }}</th>
|
||||
<th class="cd-table-header">{{ $t('skillimprovement.te') }}</th>
|
||||
<th class="cd-table-header">{{ $t('skillimprovement.category') }}</th>
|
||||
<th class="cd-table-header">{{ $t('skillimprovement.difficulty') }}</th>
|
||||
<th class="cd-table-header"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="isLoading">
|
||||
<td colspan="6">{{ $t('common.loading') }}</td>
|
||||
</tr>
|
||||
<tr v-if="creatingNew">
|
||||
<td>New</td>
|
||||
<td colspan="5">
|
||||
<div class="edit-form">
|
||||
<div class="edit-row">
|
||||
<label>{{ $t('skillimprovement.level') }}</label>
|
||||
<input v-model.number="newItem.current_level" type="number" />
|
||||
<label class="inline-label">{{ $t('skillimprovement.te') }}</label>
|
||||
<input v-model.number="newItem.te_required" type="number" />
|
||||
</div>
|
||||
<div class="edit-row">
|
||||
<label>{{ $t('skillimprovement.category') }}</label>
|
||||
<select v-model.number="newItem.category_id">
|
||||
<option v-for="cat in categoryOptions" :key="cat.id" :value="cat.id">
|
||||
{{ cat.label }}
|
||||
</option>
|
||||
</select>
|
||||
<label class="inline-label">{{ $t('skillimprovement.difficulty') }}</label>
|
||||
<select v-model.number="newItem.difficulty_id">
|
||||
<option v-for="diff in difficultyOptions" :key="diff.id" :value="diff.id">
|
||||
{{ diff.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="edit-actions">
|
||||
<button class="btn-primary btn-save" :disabled="isSaving" @click="saveCreate">
|
||||
<span v-if="!isSaving">{{ $t('common.save') }}</span>
|
||||
<span v-else>{{ $t('common.saving') }}</span>
|
||||
</button>
|
||||
<button class="btn-cancel" :disabled="isSaving" @click="cancelCreate">
|
||||
{{ $t('common.cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<template v-for="cost in costs" :key="cost.id">
|
||||
<tr v-if="editingId !== cost.id">
|
||||
<td>{{ cost.id }}</td>
|
||||
<td>{{ cost.current_level }}</td>
|
||||
<td>{{ cost.te_required }}</td>
|
||||
<td>{{ displayCategory(cost) }}</td>
|
||||
<td>{{ displayDifficulty(cost) }}</td>
|
||||
<td><button @click="startEdit(cost)">{{ $t('common.edit') }}</button></td>
|
||||
<p class="si-description">{{ $t('skillimprovement.description') }}</p>
|
||||
|
||||
<div class="si-category-tabs">
|
||||
<button
|
||||
v-for="cat in availableCategories"
|
||||
:key="cat.id"
|
||||
:class="{ active: selectedCategoryId === cat.id }"
|
||||
@click="selectedCategoryId = cat.id"
|
||||
>
|
||||
{{ cat.name }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="cd-view">
|
||||
<p>{{ $t('common.loading') }}</p>
|
||||
</div>
|
||||
|
||||
<template v-if="!isLoading && selectedCategoryId">
|
||||
<!-- Lernen section -->
|
||||
<div class="cd-view">
|
||||
<h3>{{ $t('skillimprovement.lernen') }}</h3>
|
||||
<div class="cd-list">
|
||||
<table class="cd-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="cd-table-header">{{ $t('skillimprovement.difficulty') }}</th>
|
||||
<th class="cd-table-header">{{ $t('skillimprovement.le') }}</th>
|
||||
<th class="cd-table-header">{{ $t('skillimprovement.skills') }}</th>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td>{{ cost.id }}</td>
|
||||
<td colspan="5">
|
||||
<div class="edit-form">
|
||||
<div class="edit-row">
|
||||
<label>{{ $t('skillimprovement.level') }}</label>
|
||||
<input v-model.number="editedItem.current_level" type="number" />
|
||||
<label class="inline-label">{{ $t('skillimprovement.te') }}</label>
|
||||
<input v-model.number="editedItem.te_required" type="number" />
|
||||
</div>
|
||||
<div class="edit-row">
|
||||
<label>{{ $t('skillimprovement.category') }}</label>
|
||||
<select v-model.number="editedItem.category_id">
|
||||
<option v-for="cat in categoryOptions" :key="cat.id" :value="cat.id">
|
||||
{{ cat.label }}
|
||||
</option>
|
||||
</select>
|
||||
<label class="inline-label">{{ $t('skillimprovement.difficulty') }}</label>
|
||||
<select v-model.number="editedItem.difficulty_id">
|
||||
<option v-for="diff in difficultyOptions" :key="diff.id" :value="diff.id">
|
||||
{{ diff.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="edit-actions">
|
||||
<button class="btn-primary btn-save" :disabled="isSaving" @click="saveEdit">
|
||||
<span v-if="!isSaving">{{ $t('common.save') }}</span>
|
||||
<span v-else>{{ $t('common.saving') }}</span>
|
||||
</button>
|
||||
<button class="btn-cancel" :disabled="isSaving" @click="cancelEdit">
|
||||
{{ $t('common.cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in lernenRows" :key="row.key">
|
||||
<td>{{ row.difficultyName }}</td>
|
||||
<td
|
||||
class="si-cell"
|
||||
@click="startEditLernen(row)"
|
||||
>
|
||||
<template v-if="editingLernenKey === row.key">
|
||||
<input
|
||||
v-model.number="editLernenValue"
|
||||
type="number"
|
||||
min="0"
|
||||
class="si-input"
|
||||
@keyup.enter="saveLernenEdit(row)"
|
||||
@keyup.escape="cancelLernenEdit"
|
||||
@blur="saveLernenEdit(row)"
|
||||
ref="lernenInput"
|
||||
/>
|
||||
<span class="si-le-unit">LE</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ row.learnCost }} LE
|
||||
</template>
|
||||
</td>
|
||||
<td>{{ row.skillsDisplay }}</td>
|
||||
</tr>
|
||||
<tr v-if="lernenRows.length === 0">
|
||||
<td colspan="3" class="si-empty">{{ $t('skillimprovement.noLernenData') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="si-hint">{{ $t('skillimprovement.clickLeToEdit') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Verbessern (TE) section -->
|
||||
<div class="cd-view">
|
||||
<h3>{{ $t('skillimprovement.verbessern') }}</h3>
|
||||
<div class="cd-list">
|
||||
<table class="cd-table si-matrix">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="cd-table-header si-sticky-col">{{ $t('skillimprovement.difficulty') }}</th>
|
||||
<th v-for="level in matrixLevels" :key="level" class="cd-table-header">
|
||||
+{{ level }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="diff in matrixDifficulties" :key="diff.id">
|
||||
<td class="si-sticky-col si-row-header">{{ diff.name }}</td>
|
||||
<td
|
||||
v-for="level in matrixLevels"
|
||||
:key="level"
|
||||
class="si-cell"
|
||||
@click="startEditTE(diff.id, level)"
|
||||
>
|
||||
<template v-if="editingCell === cellKey(diff.id, level)">
|
||||
<input
|
||||
v-model.number="editValue"
|
||||
type="number"
|
||||
min="0"
|
||||
class="si-input"
|
||||
@keyup.enter="saveEditTE(diff.id, level)"
|
||||
@keyup.escape="cancelEdit"
|
||||
@blur="saveEditTE(diff.id, level)"
|
||||
ref="cellInput"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ displayTE(diff.id, level) }}
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
<tr v-if="matrixDifficulties.length === 0">
|
||||
<td :colspan="matrixLevels.length + 1" class="si-empty">{{ $t('skillimprovement.noVerbessernData') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="si-hint">{{ $t('skillimprovement.clickToEdit') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.si-description {
|
||||
margin: 8px 0 4px;
|
||||
font-style: italic;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.si-category-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin: 10px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.si-category-tabs button {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.si-category-tabs button:hover {
|
||||
background: var(--color-background-mute);
|
||||
}
|
||||
.si-category-tabs button.active {
|
||||
background: var(--color-background-mute);
|
||||
font-weight: bold;
|
||||
border-color: var(--color-text);
|
||||
}
|
||||
.si-matrix {
|
||||
min-width: max-content;
|
||||
}
|
||||
.si-matrix th,
|
||||
.si-matrix td {
|
||||
text-align: center;
|
||||
min-width: 50px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.si-sticky-col {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
background: var(--color-background);
|
||||
z-index: 1;
|
||||
}
|
||||
.si-row-header {
|
||||
text-align: left;
|
||||
font-weight: bold;
|
||||
padding-right: 12px;
|
||||
}
|
||||
.si-cell {
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.si-cell:hover {
|
||||
background: var(--color-background-mute);
|
||||
}
|
||||
.si-input {
|
||||
width: 55px;
|
||||
text-align: center;
|
||||
padding: 2px 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.si-hint {
|
||||
margin-top: 6px;
|
||||
font-size: 0.85em;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.si-le-unit {
|
||||
margin-left: 2px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.si-empty {
|
||||
font-style: italic;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.error-box {
|
||||
margin: 10px 0;
|
||||
padding: 10px 12px;
|
||||
@@ -120,24 +203,6 @@
|
||||
border: 1px solid #f5c2c2;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.edit-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.edit-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.inline-label {
|
||||
margin-left: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
@@ -147,127 +212,226 @@ export default {
|
||||
name: 'SkillImprovementCostView',
|
||||
data() {
|
||||
return {
|
||||
costs: [],
|
||||
editingId: null,
|
||||
editedItem: null,
|
||||
creatingNew: false,
|
||||
newItem: null,
|
||||
categories: [],
|
||||
difficulties: [],
|
||||
skillCatDiffs: [],
|
||||
improvementCosts: [],
|
||||
selectedCategoryId: null,
|
||||
isLoading: false,
|
||||
isSaving: false,
|
||||
error: '',
|
||||
editingCell: null,
|
||||
editValue: 0,
|
||||
editingLernenKey: null,
|
||||
editLernenValue: 0,
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
await this.loadCosts()
|
||||
},
|
||||
computed: {
|
||||
categoryOptions() {
|
||||
const seen = new Map()
|
||||
this.costs.forEach(c => {
|
||||
const id = c.category_id ?? c.skillCategoryId
|
||||
const name = c.category_name || c.skillCategoryName
|
||||
if (id != null && !seen.has(id)) {
|
||||
seen.set(id, name ? `${name} (${id})` : `${id}`)
|
||||
}
|
||||
})
|
||||
return Array.from(seen.entries()).map(([id, label]) => ({ id, label }))
|
||||
availableCategories() {
|
||||
const catIdsWithSKD = new Set(this.skillCatDiffs.map(d => d.skill_category?.id))
|
||||
const catIdsWithIC = new Set(this.improvementCosts.map(c => c.skillCategoryId))
|
||||
return this.categories.filter(cat =>
|
||||
catIdsWithSKD.has(cat.id) || catIdsWithIC.has(cat.id)
|
||||
)
|
||||
},
|
||||
difficultyOptions() {
|
||||
const seen = new Map()
|
||||
this.costs.forEach(c => {
|
||||
const id = c.difficulty_id ?? c.skillDifficultyId
|
||||
const name = c.difficulty_name || c.skillDifficultyName
|
||||
if (id != null && !seen.has(id)) {
|
||||
seen.set(id, name ? `${name} (${id})` : `${id}`)
|
||||
|
||||
lernenRows() {
|
||||
if (!this.selectedCategoryId) return []
|
||||
|
||||
const items = this.skillCatDiffs.filter(d =>
|
||||
d.skill_category?.id === this.selectedCategoryId
|
||||
)
|
||||
|
||||
const groups = new Map()
|
||||
for (const item of items) {
|
||||
const diffName = item.skill_difficulty?.name || item.skillDifficulty || ''
|
||||
const diffId = item.skill_difficulty?.id || 0
|
||||
const lc = item.learn_cost
|
||||
const key = `${diffId}:${lc}`
|
||||
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, {
|
||||
key,
|
||||
difficultyId: diffId,
|
||||
difficultyName: diffName,
|
||||
learnCost: lc,
|
||||
itemIds: [],
|
||||
skills: [],
|
||||
})
|
||||
}
|
||||
})
|
||||
return Array.from(seen.entries()).map(([id, label]) => ({ id, label }))
|
||||
|
||||
groups.get(key).itemIds.push(item.id)
|
||||
const skill = item.skill
|
||||
if (skill) {
|
||||
const be = skill.bonuseigenschaft || '-'
|
||||
groups.get(key).skills.push(`${skill.name}+${skill.initialwert} (${be})`)
|
||||
}
|
||||
}
|
||||
|
||||
const rows = Array.from(groups.values())
|
||||
rows.sort((a, b) => a.difficultyId - b.difficultyId || a.learnCost - b.learnCost)
|
||||
|
||||
for (const row of rows) {
|
||||
row.skills.sort()
|
||||
row.skillsDisplay = row.skills.join(', ')
|
||||
}
|
||||
return rows
|
||||
},
|
||||
|
||||
categoryImprovementCosts() {
|
||||
if (!this.selectedCategoryId) return []
|
||||
return this.improvementCosts.filter(c => c.skillCategoryId === this.selectedCategoryId)
|
||||
},
|
||||
|
||||
matrixLevels() {
|
||||
const levels = [...new Set(this.categoryImprovementCosts.map(c => c.current_level))]
|
||||
levels.sort((a, b) => a - b)
|
||||
return levels
|
||||
},
|
||||
|
||||
matrixDifficulties() {
|
||||
const seen = new Map()
|
||||
for (const c of this.categoryImprovementCosts) {
|
||||
const id = c.skillDifficultyId
|
||||
if (!seen.has(id)) {
|
||||
const name = c.difficulty_name || this.difficulties.find(d => d.id === id)?.name || `${id}`
|
||||
seen.set(id, { id, name })
|
||||
}
|
||||
}
|
||||
return Array.from(seen.values()).sort((a, b) => a.id - b.id)
|
||||
},
|
||||
},
|
||||
async created() {
|
||||
await this.loadAll()
|
||||
},
|
||||
methods: {
|
||||
displayCategory(cost) {
|
||||
return cost.category_name || cost.skillCategoryName || cost.skillCategoryId || cost.category_id
|
||||
cellKey(diffId, level) {
|
||||
return `${diffId}:${level}`
|
||||
},
|
||||
displayDifficulty(cost) {
|
||||
return cost.difficulty_name || cost.skillDifficultyName || cost.skillDifficultyId || cost.difficulty_id
|
||||
},
|
||||
async loadCosts() {
|
||||
|
||||
async loadAll() {
|
||||
this.isLoading = true
|
||||
this.error = ''
|
||||
try {
|
||||
const resp = await API.get('/api/maintenance/skill-improvement-cost2')
|
||||
this.costs = resp.data?.costs || []
|
||||
const [catResp, diffResp, scdResp, icResp] = await Promise.all([
|
||||
API.get('/api/maintenance/skill-categories'),
|
||||
API.get('/api/maintenance/skill-difficulties'),
|
||||
API.get('/api/maintenance/skill-category-difficulties'),
|
||||
API.get('/api/maintenance/skill-improvement-cost2'),
|
||||
])
|
||||
this.categories = catResp.data?.skill_categories || []
|
||||
this.difficulties = diffResp.data?.skill_difficulties || []
|
||||
this.skillCatDiffs = scdResp.data?.items || []
|
||||
this.improvementCosts = icResp.data?.costs || []
|
||||
|
||||
if (this.availableCategories.length > 0 && !this.selectedCategoryId) {
|
||||
this.selectedCategoryId = this.availableCategories[0].id
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load costs:', err)
|
||||
console.error('Failed to load skill improvement data:', err)
|
||||
this.error = err.response?.data?.error || err.message
|
||||
} finally {
|
||||
this.isLoading = false
|
||||
}
|
||||
},
|
||||
startEdit(cost) {
|
||||
this.editingId = cost.id
|
||||
this.editedItem = {
|
||||
...cost,
|
||||
category_id: cost.category_id ?? cost.skillCategoryId,
|
||||
difficulty_id: cost.difficulty_id ?? cost.skillDifficultyId,
|
||||
}
|
||||
|
||||
findImprovementCost(diffId, level) {
|
||||
return this.categoryImprovementCosts.find(
|
||||
c => c.skillDifficultyId === diffId && c.current_level === level
|
||||
)
|
||||
},
|
||||
cancelEdit() {
|
||||
this.editingId = null
|
||||
this.editedItem = null
|
||||
|
||||
displayTE(diffId, level) {
|
||||
const entry = this.findImprovementCost(diffId, level)
|
||||
if (!entry) return '-'
|
||||
return entry.te_required === 0 ? '-' : entry.te_required
|
||||
},
|
||||
startCreate() {
|
||||
|
||||
startEditTE(diffId, level) {
|
||||
const entry = this.findImprovementCost(diffId, level)
|
||||
if (!entry) return
|
||||
this.cancelEdit()
|
||||
const defaultCategory = this.categoryOptions[0]?.id ?? null
|
||||
const defaultDifficulty = this.difficultyOptions[0]?.id ?? null
|
||||
this.newItem = {
|
||||
current_level: 0,
|
||||
te_required: 0,
|
||||
category_id: defaultCategory,
|
||||
difficulty_id: defaultDifficulty,
|
||||
}
|
||||
this.creatingNew = true
|
||||
this.editingCell = this.cellKey(diffId, level)
|
||||
this.editValue = entry.te_required
|
||||
this.$nextTick(() => {
|
||||
const inputs = this.$refs.cellInput
|
||||
if (inputs) {
|
||||
const el = Array.isArray(inputs) ? inputs[0] : inputs
|
||||
el?.focus()
|
||||
el?.select()
|
||||
}
|
||||
})
|
||||
},
|
||||
cancelCreate() {
|
||||
this.creatingNew = false
|
||||
this.newItem = null
|
||||
},
|
||||
async saveEdit() {
|
||||
if (!this.editedItem) return
|
||||
const payload = {
|
||||
current_level: this.editedItem.current_level,
|
||||
te_required: this.editedItem.te_required,
|
||||
category_id: this.editedItem.category_id,
|
||||
difficulty_id: this.editedItem.difficulty_id,
|
||||
|
||||
async saveEditTE(diffId, level) {
|
||||
const entry = this.findImprovementCost(diffId, level)
|
||||
if (!entry || this.editingCell !== this.cellKey(diffId, level)) return
|
||||
if (entry.te_required === this.editValue) {
|
||||
this.cancelEdit()
|
||||
return
|
||||
}
|
||||
this.isSaving = true
|
||||
try {
|
||||
const resp = await API.put(`/api/maintenance/skill-improvement-cost2/${this.editingId}`, payload)
|
||||
const idx = this.costs.findIndex(c => c.id === this.editingId)
|
||||
if (idx !== -1) this.costs.splice(idx, 1, resp.data)
|
||||
const resp = await API.put(`/api/maintenance/skill-improvement-cost2/${entry.id}`, {
|
||||
current_level: entry.current_level,
|
||||
te_required: this.editValue,
|
||||
category_id: entry.skillCategoryId,
|
||||
difficulty_id: entry.skillDifficultyId,
|
||||
})
|
||||
const idx = this.improvementCosts.findIndex(c => c.id === entry.id)
|
||||
if (idx !== -1) this.improvementCosts.splice(idx, 1, resp.data)
|
||||
this.cancelEdit()
|
||||
} catch (err) {
|
||||
console.error('Failed to save cost:', err)
|
||||
console.error('Failed to save TE cost:', err)
|
||||
this.error = err.response?.data?.error || err.message
|
||||
} finally {
|
||||
this.isSaving = false
|
||||
}
|
||||
},
|
||||
async saveCreate() {
|
||||
if (!this.newItem) return
|
||||
const payload = {
|
||||
current_level: this.newItem.current_level,
|
||||
te_required: this.newItem.te_required,
|
||||
category_id: this.newItem.category_id,
|
||||
difficulty_id: this.newItem.difficulty_id,
|
||||
|
||||
cancelEdit() {
|
||||
this.editingCell = null
|
||||
this.editValue = 0
|
||||
},
|
||||
|
||||
startEditLernen(row) {
|
||||
this.cancelEdit()
|
||||
this.editingLernenKey = row.key
|
||||
this.editLernenValue = row.learnCost
|
||||
this.$nextTick(() => {
|
||||
const inputs = this.$refs.lernenInput
|
||||
if (inputs) {
|
||||
const el = Array.isArray(inputs) ? inputs[0] : inputs
|
||||
el?.focus()
|
||||
el?.select()
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
cancelLernenEdit() {
|
||||
this.editingLernenKey = null
|
||||
this.editLernenValue = 0
|
||||
},
|
||||
|
||||
async saveLernenEdit(row) {
|
||||
if (this.editingLernenKey !== row.key) return
|
||||
if (row.learnCost === this.editLernenValue) {
|
||||
this.cancelLernenEdit()
|
||||
return
|
||||
}
|
||||
const newValue = this.editLernenValue
|
||||
this.cancelLernenEdit()
|
||||
this.isSaving = true
|
||||
try {
|
||||
const resp = await API.post('/api/maintenance/skill-improvement-cost2', payload)
|
||||
this.costs.push(resp.data)
|
||||
this.cancelCreate()
|
||||
for (const id of row.itemIds) {
|
||||
const resp = await API.put(`/api/maintenance/skill-category-difficulties/${id}`, {
|
||||
learn_cost: newValue,
|
||||
})
|
||||
const idx = this.skillCatDiffs.findIndex(d => d.id === id)
|
||||
if (idx !== -1) this.skillCatDiffs.splice(idx, 1, resp.data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create cost:', err)
|
||||
console.error('Failed to save learn cost:', err)
|
||||
this.error = err.response?.data?.error || err.message
|
||||
} finally {
|
||||
this.isSaving = false
|
||||
|
||||
+29
-10
@@ -306,6 +306,7 @@ export default {
|
||||
litsource:'Literaturquellen',
|
||||
misc:'Sonstige',
|
||||
skillimprovement:'Steigerungskosten',
|
||||
learningcost:'Lernkosten',
|
||||
},
|
||||
believe: {
|
||||
title: 'Glaubensrichtungen',
|
||||
@@ -364,17 +365,35 @@ export default {
|
||||
saving: 'Speichern...',
|
||||
cancel: 'Abbrechen'
|
||||
},
|
||||
learningcost: {
|
||||
title: 'Lernkosten',
|
||||
headerEPPerTE: 'EP-Kosten für 1 Trainingseinheit (TE)',
|
||||
headerEPPerLE: 'EP-Kosten für 1 Lerneinheit (LE) für Zauber',
|
||||
headerSpellLevelLE: 'Benötigte LE pro Zauberstufe',
|
||||
class: 'Klasse',
|
||||
epPerTEDesc: '1 Lerneinheit (LE) kostet das Dreifache an EP (+ 6 EP für Elfen).',
|
||||
epPerLEDesc: 'EP-Kosten für 1 Lerneinheit (LE) für Zauber nach Charakterklasse und Zauberschule (+ 6 EP für Elfen).',
|
||||
spellLevelLEDesc: 'Benötigte Lerneinheiten (LE) pro Zauberstufe.',
|
||||
goldCostTE: 'Geldkosten: 20 GS je TE, 200 GS je LE.',
|
||||
goldCostLE: 'Geldkosten: 100 GS je LE.',
|
||||
maSpecialNote: '* Ma erhalten LE für Sprüche aus ihrem Spezialgebiet für 30 EP.',
|
||||
scrollLearnNote: 'Lernen von Spruchrollen: 1/3 EP je LE bei Erfolg, pauschal 20 GS je Lernversuch.',
|
||||
spellLevel: 'Zauberstufe',
|
||||
leRequired: 'LE erforderlich',
|
||||
clickToEdit: 'Klicke auf einen Wert um ihn zu bearbeiten.',
|
||||
},
|
||||
skillimprovement: {
|
||||
title: 'Fertigkeitssteigerungskosten',
|
||||
id: 'ID',
|
||||
level: 'Aktueller Wert',
|
||||
te: 'TE erforderlich',
|
||||
category: 'Kategorie-ID',
|
||||
difficulty: 'Schwierigkeits-ID',
|
||||
edit: 'Bearbeiten',
|
||||
save: 'Speichern',
|
||||
saving: 'Speichern...',
|
||||
cancel: 'Abbrechen'
|
||||
title: 'Lern- und Trainingslisten',
|
||||
description: 'Die Zahl an Lern- und Trainingseinheiten f\u00fcr ein und dieselbe Fertigkeit stimmen in allen Gruppen \u00fcberein. Die Gruppenzugeh\u00f6rigkeit entscheidet nur dar\u00fcber, wie viele EP der Abenteurer abh\u00e4ngig von seinem Typ f\u00fcr LE und TE bezahlen muss.',
|
||||
lernen: 'Lernen',
|
||||
verbessern: 'Verbessern (TE)',
|
||||
difficulty: 'Schwierigkeit',
|
||||
le: 'LE',
|
||||
skills: 'Fertigkeiten',
|
||||
noLernenData: 'Keine Lerndaten f\u00fcr diese Kategorie vorhanden.',
|
||||
noVerbessernData: 'Keine Verbesserungsdaten f\u00fcr diese Kategorie vorhanden.',
|
||||
clickToEdit: 'Klicke auf einen Wert um ihn zu bearbeiten.',
|
||||
clickLeToEdit: 'Klicke auf einen LE-Wert um ihn zu bearbeiten.',
|
||||
},
|
||||
search:'Suche',
|
||||
newEntry:'Neuer Eintrag',
|
||||
|
||||
+29
-10
@@ -302,6 +302,7 @@ export default {
|
||||
litsource:'Sources',
|
||||
misc:'Misc',
|
||||
skillimprovement:'Improvement Costs',
|
||||
learningcost:'Learning Costs',
|
||||
},
|
||||
believe: {
|
||||
title: 'Beliefs',
|
||||
@@ -360,17 +361,35 @@ export default {
|
||||
saving: 'Saving...',
|
||||
cancel: 'Cancel'
|
||||
},
|
||||
learningcost: {
|
||||
title: 'Learning Costs',
|
||||
headerEPPerTE: 'EP Costs per Training Unit (TE)',
|
||||
headerEPPerLE: 'EP Costs per Learning Unit (LE) for Spells',
|
||||
headerSpellLevelLE: 'Required LE per Spell Level',
|
||||
class: 'Class',
|
||||
epPerTEDesc: '1 Learning Unit (LE) costs triple the EP (+ 6 EP for Elves).',
|
||||
epPerLEDesc: 'EP costs for 1 Learning Unit (LE) for spells by character class and spell school (+ 6 EP for Elves).',
|
||||
spellLevelLEDesc: 'Required learning units (LE) per spell level.',
|
||||
goldCostTE: 'Gold costs: 20 GS per TE, 200 GS per LE.',
|
||||
goldCostLE: 'Gold costs: 100 GS per LE.',
|
||||
maSpecialNote: '* Magicians receive LE for spells in their specialization for 30 EP.',
|
||||
scrollLearnNote: 'Learning from spell scrolls: 1/3 EP per LE on success, flat 20 GS per attempt.',
|
||||
spellLevel: 'Spell Level',
|
||||
leRequired: 'LE required',
|
||||
clickToEdit: 'Click a value to edit it.',
|
||||
},
|
||||
skillimprovement: {
|
||||
title: 'Skill Improvement Costs',
|
||||
id: 'ID',
|
||||
level: 'Current level',
|
||||
te: 'TE required',
|
||||
category: 'Category ID',
|
||||
difficulty: 'Difficulty ID',
|
||||
edit: 'Edit',
|
||||
save: 'Save',
|
||||
saving: 'Saving...',
|
||||
cancel: 'Cancel'
|
||||
title: 'Learning & Training Lists',
|
||||
description: 'The number of learning and training units for any given skill is the same across all groups. Group membership only determines how many EP the adventurer must pay for LE and TE based on their type.',
|
||||
lernen: 'Learning',
|
||||
verbessern: 'Improving (TE)',
|
||||
difficulty: 'Difficulty',
|
||||
le: 'LE',
|
||||
skills: 'Skills',
|
||||
noLernenData: 'No learning data for this category.',
|
||||
noVerbessernData: 'No improvement data for this category.',
|
||||
clickToEdit: 'Click a value to edit it.',
|
||||
clickLeToEdit: 'Click an LE value to edit it.',
|
||||
},
|
||||
search:'Search',
|
||||
newEntry:'New Entry',
|
||||
|
||||
Reference in New Issue
Block a user