removed packages and views that where part of the original application

This commit is contained in:
2026-04-22 09:40:08 +02:00
parent 5f059c27ba
commit 3274c1c668
232 changed files with 12 additions and 78695 deletions
-436
View File
@@ -1,436 +0,0 @@
<template>
<div class="audit-log-view">
<h4>{{ $t('audit.title', 'Änderungsprotokoll') }}</h4>
<!-- Filter Controls -->
<div class="filter-controls">
<div class="filter-group">
<label>{{ $t('audit.filter_by_field', 'Filter nach Feld') }}:</label>
<select v-model="selectedField" @change="loadAuditLog" class="filter-select">
<option value="">{{ $t('audit.all_fields', 'Alle Felder') }}</option>
<option value="experience_points">{{ $t('audit.experience_points', 'Erfahrungspunkte') }}</option>
<option value="gold">{{ $t('audit.gold', 'Gold') }}</option>
<option value="silver">{{ $t('audit.silver', 'Silber') }}</option>
<option value="copper">{{ $t('audit.copper', 'Kupfer') }}</option>
</select>
</div>
<div class="filter-group">
<label>{{ $t('audit.filter_by_date', 'Zeitraum') }}:</label>
<select v-model="selectedDateRange" @change="loadAuditLog" class="filter-select">
<option value="">{{ $t('audit.all_time', 'Alle Zeit') }}</option>
<option value="today">{{ $t('audit.today', 'Heute') }}</option>
<option value="week">{{ $t('audit.this_week', 'Diese Woche') }}</option>
<option value="month">{{ $t('audit.this_month', 'Dieser Monat') }}</option>
<option value="custom">{{ $t('audit.custom_range', 'Benutzerdefiniert') }}</option>
</select>
</div>
<div v-if="selectedDateRange === 'custom'" class="date-range-group">
<input
v-model="customDateFrom"
type="date"
@change="loadAuditLog"
class="date-input"
/>
<span>bis</span>
<input
v-model="customDateTo"
type="date"
@change="loadAuditLog"
class="date-input"
/>
</div>
<div class="filter-group">
<label>
<input
v-model="groupByDate"
type="checkbox"
class="checkbox-input"
/>
{{ $t('audit.group_by_date', 'Nach Datum gruppieren') }}
</label>
</div>
<button @click="loadAuditLog" class="btn-refresh" :disabled="isLoading">
<span v-if="isLoading"></span>
<span v-else>🔄</span>
{{ $t('audit.refresh', 'Aktualisieren') }}
</button>
</div>
<!-- Statistics -->
<div v-if="stats" class="stats-section">
<h5>{{ $t('audit.statistics', 'Statistiken') }}</h5>
<div class="stats-grid">
<div class="stat-item">
<span class="stat-label">{{ $t('audit.total_changes', 'Gesamte Änderungen') }}:</span>
<span class="stat-value">{{ stats.total_changes }}</span>
</div>
<div class="stat-item ep-stat">
<span class="stat-label">{{ $t('audit.ep_spent', 'EP ausgegeben') }}:</span>
<span class="stat-value negative">{{ stats.total_ep_spent }}</span>
</div>
<div class="stat-item ep-stat">
<span class="stat-label">{{ $t('audit.ep_gained', 'EP erhalten') }}:</span>
<span class="stat-value positive">{{ stats.total_ep_gained }}</span>
</div>
<div class="stat-item gold-stat">
<span class="stat-label">{{ $t('audit.gold_spent', 'Gold ausgegeben') }}:</span>
<span class="stat-value negative">{{ stats.total_gold_spent }}</span>
</div>
<div class="stat-item gold-stat">
<span class="stat-label">{{ $t('audit.gold_gained', 'Gold erhalten') }}:</span>
<span class="stat-value positive">{{ stats.total_gold_gained }}</span>
</div>
</div>
</div>
<!-- Audit Log Entries -->
<div class="audit-entries">
<div v-if="isLoading" class="loading">
{{ $t('audit.loading', 'Lädt...') }}
</div>
<div v-else-if="auditEntries.length === 0" class="no-entries">
{{ $t('audit.no_entries', 'Keine Änderungen gefunden') }}
</div>
<div v-else>
<div v-if="groupByDate">
<div v-for="(entries, date) in groupedEntries" :key="date" class="date-group">
<h6 class="date-group-header">{{ formatDateHeader(date) }}</h6>
<div
v-for="entry in entries"
:key="entry.id"
class="audit-entry"
:class="[
entry.difference > 0 ? 'positive-change' : 'negative-change',
`field-${entry.field_name}`
]"
>
<div class="entry-header">
<div class="entry-field">
<span class="field-icon">{{ getFieldIcon(entry.field_name) }}</span>
<span class="field-name">{{ getFieldDisplayName(entry.field_name) }}</span>
</div>
<div class="entry-timestamp">
<div class="timestamp-time">{{ formatTime(entry.timestamp) }}</div>
<div class="timestamp-relative">{{ formatRelativeTime(entry.timestamp) }}</div>
</div>
</div>
<div class="entry-content">
<div class="value-change">
<span class="old-value">{{ entry.old_value }}</span>
<span class="arrow"></span>
<span class="new-value">{{ entry.new_value }}</span>
<span class="difference" :class="entry.difference > 0 ? 'positive' : 'negative'">
({{ entry.difference > 0 ? '+' : '' }}{{ entry.difference }})
</span>
</div>
<div class="entry-reason">
<span class="reason-label">{{ $t('audit.reason', 'Grund') }}:</span>
<span class="reason-value">{{ getReasonDisplayName(entry.reason) }}</span>
</div>
<div v-if="entry.notes" class="entry-notes">
<span class="notes-label">{{ $t('audit.notes', 'Notizen') }}:</span>
<span class="notes-value">{{ entry.notes }}</span>
</div>
</div>
</div>
</div>
</div>
<div v-else>
<div
v-for="entry in auditEntries"
:key="entry.id"
class="audit-entry"
:class="[
entry.difference > 0 ? 'positive-change' : 'negative-change',
`field-${entry.field_name}`
]"
>
<div class="entry-header">
<div class="entry-field">
<span class="field-icon">{{ getFieldIcon(entry.field_name) }}</span>
<span class="field-name">{{ getFieldDisplayName(entry.field_name) }}</span>
</div>
<div class="entry-timestamp">
<div class="timestamp-date">{{ formatDate(entry.timestamp) }}</div>
<div class="timestamp-time">{{ formatTime(entry.timestamp) }}</div>
<div class="timestamp-relative">{{ formatRelativeTime(entry.timestamp) }}</div>
</div>
</div>
<div class="entry-content">
<div class="value-change">
<span class="old-value">{{ entry.old_value }}</span>
<span class="arrow"></span>
<span class="new-value">{{ entry.new_value }}</span>
<span class="difference" :class="entry.difference > 0 ? 'positive' : 'negative'">
({{ entry.difference > 0 ? '+' : '' }}{{ entry.difference }})
</span>
</div>
<div class="entry-reason">
<span class="reason-label">{{ $t('audit.reason', 'Grund') }}:</span>
<span class="reason-value">{{ getReasonDisplayName(entry.reason) }}</span>
</div>
<div v-if="entry.notes" class="entry-notes">
<span class="notes-label">{{ $t('audit.notes', 'Notizen') }}:</span>
<span class="notes-value">{{ entry.notes }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import API from '@/utils/api'
export default {
name: "AuditLogView",
props: {
character: {
type: Object,
required: true
}
},
data() {
return {
auditEntries: [],
stats: null,
selectedField: '',
selectedDateRange: '',
customDateFrom: '',
customDateTo: '',
groupByDate: true,
isLoading: false
};
},
computed: {
groupedEntries() {
if (!this.groupByDate) return {};
const grouped = {};
this.auditEntries.forEach(entry => {
const date = new Date(entry.timestamp).toLocaleDateString('de-DE');
if (!grouped[date]) {
grouped[date] = [];
}
grouped[date].push(entry);
});
return grouped;
}
},
created() {
this.$api = API;
this.loadAuditLog();
this.loadStats();
},
methods: {
async loadAuditLog() {
if (!this.character?.id) return;
this.isLoading = true;
try {
let url = `/api/characters/${this.character.id}/audit-log`;
const params = new URLSearchParams();
if (this.selectedField) {
params.append('field', this.selectedField);
}
// Datumsfilter
if (this.selectedDateRange) {
const now = new Date();
let fromDate, toDate;
switch (this.selectedDateRange) {
case 'today':
fromDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
toDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59);
break;
case 'week':
const dayOfWeek = now.getDay();
const mondayOffset = dayOfWeek === 0 ? 6 : dayOfWeek - 1; // Montag als Wochenbeginn
fromDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - mondayOffset);
toDate = new Date();
break;
case 'month':
fromDate = new Date(now.getFullYear(), now.getMonth(), 1);
toDate = new Date();
break;
case 'custom':
if (this.customDateFrom) {
fromDate = new Date(this.customDateFrom);
}
if (this.customDateTo) {
toDate = new Date(this.customDateTo);
toDate.setHours(23, 59, 59, 999); // Ende des Tages
}
break;
}
if (fromDate) {
params.append('from', fromDate.toISOString());
}
if (toDate) {
params.append('to', toDate.toISOString());
}
}
if (params.toString()) {
url += '?' + params.toString();
}
const response = await this.$api.get(url);
this.auditEntries = response.data.entries || [];
} catch (error) {
console.error('Fehler beim Laden des Audit-Logs:', error);
this.auditEntries = [];
} finally {
this.isLoading = false;
}
},
async loadStats() {
if (!this.character?.id) return;
try {
const response = await this.$api.get(`/api/characters/${this.character.id}/audit-log/stats`);
this.stats = response.data.stats;
} catch (error) {
console.error('Fehler beim Laden der Statistiken:', error);
this.stats = null;
}
},
getFieldIcon(fieldName) {
const icons = {
'experience_points': '⭐',
'gold': '💰',
'silver': '🥈',
'copper': '🥉'
};
return icons[fieldName] || '📝';
},
getFieldDisplayName(fieldName) {
const names = {
'experience_points': 'Erfahrungspunkte',
'gold': 'Goldstücke',
'silver': 'Silberstücke',
'copper': 'Kupferstücke'
};
return names[fieldName] || fieldName;
},
getReasonDisplayName(reason) {
const reasons = {
'manual': 'Manuell',
'skill_learning': 'Fertigkeit lernen',
'skill_improvement': 'Fertigkeit verbessern',
'spell_learning': 'Zauber lernen',
'spell_improvement': 'Zauber verbessern',
'equipment': 'Ausrüstung',
'reward': 'Belohnung',
'correction': 'Korrektur',
'import': 'Import'
};
return reasons[reason] || reason;
},
formatTimestamp(timestamp) {
return new Date(timestamp).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
},
formatDate(timestamp) {
return new Date(timestamp).toLocaleDateString('de-DE', {
weekday: 'short',
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
},
formatTime(timestamp) {
return new Date(timestamp).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
},
formatRelativeTime(timestamp) {
const now = new Date();
const past = new Date(timestamp);
const diffInSeconds = Math.floor((now - past) / 1000);
if (diffInSeconds < 60) {
return 'gerade eben';
} else if (diffInSeconds < 3600) {
const minutes = Math.floor(diffInSeconds / 60);
return `vor ${minutes} Min.`;
} else if (diffInSeconds < 86400) {
const hours = Math.floor(diffInSeconds / 3600);
return `vor ${hours} Std.`;
} else if (diffInSeconds < 604800) {
const days = Math.floor(diffInSeconds / 86400);
return `vor ${days} Tag${days > 1 ? 'en' : ''}`;
} else {
const weeks = Math.floor(diffInSeconds / 604800);
return `vor ${weeks} Woche${weeks > 1 ? 'n' : ''}`;
}
},
formatDateHeader(dateString) {
const date = new Date(dateString.split('.').reverse().join('-')); // Umwandlung von dd.mm.yyyy zu yyyy-mm-dd
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
if (date.toLocaleDateString('de-DE') === today.toLocaleDateString('de-DE')) {
return 'Heute (' + dateString + ')';
} else if (date.toLocaleDateString('de-DE') === yesterday.toLocaleDateString('de-DE')) {
return 'Gestern (' + dateString + ')';
} else {
return date.toLocaleDateString('de-DE', {
weekday: 'long',
day: '2-digit',
month: 'long',
year: 'numeric'
});
}
}
},
watch: {
'character.id'() {
this.loadAuditLog();
this.loadStats();
}
}
};
</script>
<style>
/* All styles moved to main.css */
</style>
@@ -1,41 +0,0 @@
<template>
<form @submit.prevent="addItem">
<input v-model="name" placeholder="Name" required />
<input v-model.number="anzahl" type="number" placeholder="Anzahl" required />
<input v-model.number="gewicht" type="number" placeholder="Gewicht" required />
<button type="submit">Add Item</button>
</form>
</template>
<script>
import API from '../utils/api'
export default {
props: ['characterId'],
data() {
return {
name: '',
anzahl: 1,
gewicht: 0,
}
},
methods: {
async addItem() {
await API.post('/ausruestung', {
character_id: this.characterId,
name: this.name,
anzahl: this.anzahl,
gewicht: this.gewicht,
})
this.$emit('added')
this.name = ''
this.anzahl = 1
this.gewicht = 0
},
},
}
</script>
<style>
/* All common styles moved to main.css */
</style>
@@ -1,44 +0,0 @@
<template>
<div>
<h2>Ausruestung</h2>
<ul>
<li v-for="item in ausruestung" :key="item.ausruestung_id">
{{ item.name }} - {{ item.anzahl }} ({{ item.gewicht }}kg)
<button @click="deleteItem(item.ausruestung_id)">Delete</button>
</li>
</ul>
<AusruestungForm @added="fetchAusruestung" :characterId="characterId" />
</div>
</template>
<script>
import API from '../utils/api'
import AusruestungForm from './AusruestungForm.vue'
export default {
components: { AusruestungForm },
props: ['characterId'],
data() {
return {
ausruestung: [],
}
},
async created() {
this.fetchAusruestung()
},
methods: {
async fetchAusruestung() {
const response = await API.get(`/ausruestung/${this.characterId}`)
this.ausruestung = response.data
},
async deleteItem(id) {
await API.delete(`/ausruestung/${id}`)
this.fetchAusruestung()
},
},
}
</script>
<style>
/* All common styles moved to main.css */
</style>
@@ -1,371 +0,0 @@
<template>
<div class="character-creation">
<div class="creation-header">
<h1>Create New Character</h1>
<div class="progress-indicator">
<div
v-for="step in steps"
:key="step.number"
:class="['step', {
active: currentStep === step.number,
completed: currentStep > step.number,
clickable: currentStep > step.number || currentStep === step.number
}]"
@click="navigateToStep(step.number)"
>
<span class="step-number">{{ step.number }}</span>
<span class="step-title">{{ step.title }}</span>
</div>
</div>
</div>
<div class="creation-content">
<!-- Step 1: Basic Information -->
<CharacterBasicInfo
v-if="currentStep === 1"
:session-data="sessionData"
@next="handleNext"
@save="saveProgress"
/>
<!-- Step 2: Attributes -->
<CharacterAttributes
v-if="currentStep === 2"
:session-data="sessionData"
@next="handleNext"
@previous="handlePrevious"
@save="saveProgress"
/>
<!-- Step 3: Derived Values -->
<CharacterDerivedValues
v-if="currentStep === 3"
:session-data="sessionData"
@next="handleNext"
@previous="handlePrevious"
@save="saveProgress"
/>
<!-- Step 4: Skills -->
<CharacterSkills
v-if="currentStep === 4"
:session-data="sessionData"
:skill-categories="skillCategories"
@previous="handlePrevious"
@next="handleNext"
@save="saveProgress"
/>
<!-- Step 5: Spells -->
<CharacterSpells
v-if="currentStep === 5"
:session-data="sessionData"
:skill-categories="skillCategories"
@previous="handlePrevious"
@finalize="handleFinalize"
@save="saveProgress"
/>
</div>
<!-- Session Info -->
<div class="session-info">
<p>Session expires: {{ formatDate(sessionData.expires_at) }}</p>
<button @click="deleteDraft" class="delete-btn">Delete Draft</button>
</div>
</div>
</template>
<script>
import API from '../utils/api'
import CharacterBasicInfo from './CharacterCreation/CharacterBasicInfo.vue'
import CharacterAttributes from './CharacterCreation/CharacterAttributes.vue'
import CharacterDerivedValues from './CharacterCreation/CharacterDerivedValues.vue'
import CharacterSkills from './CharacterCreation/CharacterSkills.vue'
import CharacterSpells from './CharacterCreation/CharacterSpells.vue'
export default {
name: 'CharacterCreation',
components: {
CharacterBasicInfo,
CharacterAttributes,
CharacterDerivedValues,
CharacterSkills,
CharacterSpells,
},
props: {
sessionId: {
type: String,
required: true,
}
},
data() {
return {
currentStep: 1,
sessionData: {
id: '',
name: '',
rasse: '',
typ: '',
herkunft: '',
glaube: '',
geschlecht: '',
stand: '',
attributes: {},
derived_values: {},
skills: [],
spells: [],
skill_points: {},
spell_points: {},
expires_at: null,
current_step: 1,
},
steps: [
{ number: 1, title: 'Basic Info' },
{ number: 2, title: 'Attributes' },
{ number: 3, title: 'Derived Values' },
{ number: 4, title: 'Skills' },
{ number: 5, title: 'Spells' },
],
skillCategories: [],
}
},
async created() {
await this.loadSession()
await this.loadSkillCategories()
},
methods: {
async loadSession(preserveCurrentStep = false) {
try {
const token = localStorage.getItem('token')
const response = await API.get(`/api/characters/create-session/${this.sessionId}`, {
headers: { Authorization: `Bearer ${token}` },
})
this.sessionData = response.data
// Only update currentStep if not preserving it
if (!preserveCurrentStep) {
this.currentStep = response.data.current_step || 1
}
} catch (error) {
console.error('Error loading session:', error)
this.$router.push('/dashboard')
}
},
async loadSkillCategories() {
try {
const token = localStorage.getItem('token')
const response = await API.get('/api/characters/skill-categories', {
headers: { Authorization: `Bearer ${token}` },
})
this.skillCategories = response.data.categories || []
} catch (error) {
console.error('Error loading skill categories:', error)
// Fallback dummy data
this.skillCategories = [
{ name: 'körperlich', display_name: 'Körperliche Fertigkeiten', max_points: 200, points: 200 },
{ name: 'gesellschaftlich', display_name: 'Gesellschaftliche Fertigkeiten', max_points: 150, points: 150 },
{ name: 'natur', display_name: 'Natur Fertigkeiten', max_points: 100, points: 100 },
{ name: 'wissen', display_name: 'Wissens Fertigkeiten', max_points: 180, points: 180 },
{ name: 'handwerk', display_name: 'Handwerks Fertigkeiten', max_points: 120, points: 120 },
{ name: 'zauber', display_name: 'Zauber', max_points: 300, points: 300 },
]
}
},
async handleNext(data) {
try {
// Merge the new data
this.sessionData = { ...this.sessionData, ...data }
// Save progress for current step before moving to next
await this.saveProgressForStep(this.currentStep, data)
// Reload session data from backend to ensure consistent state
// Preserve currentStep as we'll increment it after
await this.loadSession(true)
// Move to next step
this.currentStep++
} catch (error) {
console.error('Failed to save progress before moving to next step:', error)
// Don't move to next step if save failed
}
},
async saveProgressForStep(step, data) {
try {
const token = localStorage.getItem('token')
let endpoint = ''
let payload = {}
switch (step) {
case 1:
endpoint = `/api/characters/create-session/${this.sessionId}/basic`
// Handle both old format and new basic_info format
const basicInfo = data.basic_info || data
payload = {
name: basicInfo.name || this.sessionData.name || '',
geschlecht: basicInfo.geschlecht || this.sessionData.geschlecht || '',
rasse: basicInfo.rasse || this.sessionData.rasse || '',
typ: basicInfo.typ || this.sessionData.typ || '',
herkunft: basicInfo.herkunft || this.sessionData.herkunft || '',
stand: basicInfo.stand || this.sessionData.stand || '',
glaube: basicInfo.glaube || this.sessionData.glaube || '',
}
// Validate that all required fields are present
if (!payload.name || !payload.geschlecht || !payload.rasse || !payload.typ || !payload.herkunft || !payload.stand) {
throw new Error(`Missing required fields: name=${payload.name}, geschlecht=${payload.geschlecht}, rasse=${payload.rasse}, typ=${payload.typ}, herkunft=${payload.herkunft}, stand=${payload.stand}`)
}
break
case 2:
endpoint = `/api/characters/create-session/${this.sessionId}/attributes`
payload = data.attributes || data
break
case 3:
endpoint = `/api/characters/create-session/${this.sessionId}/derived`
payload = data.derived_values || data
break
case 4:
endpoint = `/api/characters/create-session/${this.sessionId}/skills`
payload = {
skills: data.skills || this.sessionData.skills,
spells: data.spells || this.sessionData.spells,
skill_points: data.skill_points || this.sessionData.skill_points,
}
break
}
if (endpoint) {
const response = await API.put(endpoint, payload, {
headers: { Authorization: `Bearer ${token}` },
})
}
} catch (error) {
console.error('Error saving progress for step', step, ':', error)
// Provide more specific error messages
if (error.response && error.response.status === 401) {
alert('Your session has expired. Please log in again.')
} else if (error.response && error.response.status === 400) {
const errorMsg = error.response.data?.error || 'Invalid data submitted'
//alert(`Error saving character data: ${errorMsg}`)
} //else {
//alert('Failed to save character data. Please try again.')
//}
throw error // Re-throw to handle in calling function
}
},
handlePrevious() {
this.currentStep--
},
navigateToStep(stepNumber) {
// Only allow navigation to current step or previously completed steps
if (stepNumber <= this.currentStep) {
this.currentStep = stepNumber
// Save current progress before switching steps (no data parameter needed here)
this.saveProgress().catch(error => {
console.error('Failed to save progress during navigation:', error)
})
}
},
async saveProgress(data = null) {
try {
// Use provided data or current sessionData as fallback
const dataToSave = data || this.sessionData
// Update sessionData with new data if provided
if (data) {
this.sessionData = { ...this.sessionData, ...data }
}
// Save progress for current step
await this.saveProgressForStep(this.currentStep, dataToSave)
} catch (error) {
console.error('Failed to save progress:', error)
throw error
}
},
getUserIdFromToken() {
try {
const token = localStorage.getItem('token')
if (!token) return null
// Decode JWT token to get user ID
const base64Url = token.split('.')[1]
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
const jsonPayload = decodeURIComponent(window.atob(base64).split('').map(function(c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)
}).join(''))
const payload = JSON.parse(jsonPayload)
return payload.user_id || payload.userID || payload.sub || null
} catch (error) {
console.error('Error decoding token:', error)
return null
}
},
async handleFinalize() {
try {
const token = localStorage.getItem('token')
const userId = this.getUserIdFromToken()
const requestBody = {}
if (userId) {
requestBody.user_id = userId
}
const response = await API.post(`/api/characters/create-session/${this.sessionId}/finalize`, requestBody, {
headers: { Authorization: `Bearer ${token}` },
})
const characterId = response.data.character_id
// Success message
//alert('Character successfully created!')
// Navigate to character view or back to character list
this.$router.push(`/character/${characterId}`)
} catch (error) {
console.error('Error finalizing character:', error)
if (error.response?.data?.error) {
alert(`Error: ${error.response.data.error}`)
} else {
alert('Fehler beim Abschließen der Charakter-Erstellung')
}
}
},
async deleteDraft() {
if (confirm('Are you sure you want to delete this character draft?')) {
try {
const token = localStorage.getItem('token')
await API.delete(`/api/characters/create-session/${this.sessionId}`, {
headers: { Authorization: `Bearer ${token}` },
})
this.$router.push('/dashboard')
} catch (error) {
console.error('Error deleting session:', error)
}
}
},
formatDate(dateString) {
if (!dateString) return ''
return new Date(dateString).toLocaleDateString()
},
},
}
</script>
<style>
/* All styles moved to main.css */
</style>
@@ -1,405 +0,0 @@
<template>
<div class="attributes-form character-creation-container">
<h2>{{ $t('characters.attributes.title') }}</h2>
<p class="instruction">{{ $t('characters.attributes.instruction') }}</p>
<form @submit.prevent="handleSubmit" class="attributes-form-content">
<div class="attributes-grid">
<div class="attribute-group" v-for="attr in attributes" :key="attr.key">
<div class="attribute-row">
<label :for="attr.key" class="attribute-label">
{{ $t(`characters.attributes.${attr.key}`) }} ({{ attr.key.toUpperCase() }})
</label>
<div class="input-with-dice">
<input
:id="attr.key"
v-model.number="formData[attr.key]"
type="number"
min="1"
max="100"
required
class="attribute-input"
@input="handleAttributeChange"
/>
<button
type="button"
class="dice-btn"
@click="rollAttribute(attr.key)"
:title="attr.key === 'au' ? $t('characters.attributes.rollTooltipAu') :
$t('characters.attributes.rollTooltipOther') + ' ' + $t(`characters.attributes.${attr.key}`)"
>
🎲
</button>
</div>
</div>
<span class="attribute-description">{{ $t(`characters.attributes.${attr.key}Description`) }}</span>
<!-- Race restriction warning for AU -->
<div v-if="attr.key === 'au' && auRaceRestriction" class="race-restriction-warning">
{{ $t(`characters.attributes.raceRestriction${auRaceRestriction.raceKey}`) }}
</div>
<div v-if="lastAttributeRoll && lastAttributeRoll.attribute === attr.key" class="roll-result">
{{ attr.name }}: {{ lastAttributeRoll.roll }}
<span class="roll-breakdown">
<span v-if="lastAttributeRoll.isSpecialCalculation">
({{ lastAttributeRoll.description }})
</span>
<span v-else>
(max of {{ lastAttributeRoll.rolls.join(', ') }})
</span>
</span>
{{ lastAttributeRoll.result }}
</div>
</div>
</div>
<div class="attribute-summary">
<div class="total-points">
<strong>{{ $t('characters.attributes.totalPoints') }}: {{ totalPoints }}</strong>
</div>
<div class="average-points">
<strong>{{ $t('characters.attributes.averagePoints') }}: {{ averagePoints.toFixed(1) }}</strong>
</div>
<button type="button" @click="rollAllAttributes" class="roll-all-btn">
🎲 {{ $t('characters.attributes.rollAllAttributes') }}
</button>
</div>
<div class="form-actions">
<button type="button" @click="handlePrevious" class="prev-btn">
{{ $t('characters.attributes.previousBasicInfo') }}
</button>
<button type="submit" class="next-btn" :disabled="!isValid">
{{ $t('characters.attributes.nextDerivedValues') }}
</button>
</div>
</form>
<!-- Roll Result Overlay -->
<div v-if="showOverlay && lastAttributeRoll" class="roll-overlay" @click="hideOverlay">
<div class="roll-overlay-content">
<button class="overlay-close" @click="hideOverlay">×</button>
<div class="overlay-title">🎲 {{ lastAttributeRoll.attributeName }}</div>
<div class="overlay-roll">
{{ lastAttributeRoll.result }}
<span class="roll-breakdown">
<span v-if="lastAttributeRoll.isSpecialCalculation">
({{ lastAttributeRoll.description }})
</span>
<span v-else>
(max of {{ lastAttributeRoll.rolls.join(', ') }})
</span>
</span>
</div>
<div class="overlay-result">
{{ lastAttributeRoll.attributeName }}: {{ lastAttributeRoll.result }}
</div>
<div class="overlay-hint">{{ $t('characters.attributes.clickToClose') }}</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'CharacterAttributes',
props: {
sessionData: {
type: Object,
required: true,
}
},
emits: ['next', 'previous', 'save'],
data() {
return {
formData: {
st: 0, // Stärke
gs: 0, // Geschicklichkeit
gw: 0, // Gewandtheit
ko: 0, // Konstitution
in: 0, // Intelligenz
zt: 0, // Zaubertalent
au: 0, // Ausehen
},
attributes: [
{
key: 'st',
name: 'Stärke',
description: 'Physical strength and power'
},
{
key: 'gs',
name: 'Geschicklichkeit',
description: 'Dexterity and manual skill'
},
{
key: 'gw',
name: 'Gewandtheit',
description: 'Agility and quick reactions'
},
{
key: 'ko',
name: 'Konstitution',
description: 'Health and endurance'
},
{
key: 'in',
name: 'Intelligenz',
description: 'Learning ability and logic'
},
{
key: 'zt',
name: 'Zaubertalent',
description: 'Magical talent and mana'
},
{
key: 'au',
name: 'Aussehen',
description: 'Beauty and appearance (Race restrictions: Elfen ≥81, Gnome/Zwerge ≤80)'
},
],
totalPoints: 0,
lastAttributeRoll: null,
showOverlay: false,
overlayTimeout: null,
}
},
computed: {
isValid() {
// Check basic value range (1-100) for only the defined attributes
const definedKeys = this.attributes.map(attr => attr.key)
const relevantValues = definedKeys.map(key => this.formData[key])
const basicValid = relevantValues.every(val => val >= 1 && val <= 100)
if (!basicValid) return false
// Check race-specific AU restrictions
const race = this.sessionData.rasse || ''
const auValue = this.formData.au
if (race === 'Elfen' && auValue < 81) {
return false // Elfen must have AU ≥ 81
}
if ((race === 'Gnome' || race === 'Zwerge') && auValue > 80) {
return false // Gnome/Zwerge must have AU ≤ 80
}
return true
},
averagePoints() {
return this.totalPoints / Object.keys(this.formData).length
},
auRaceRestriction() {
const race = this.sessionData.rasse || ''
if (race === 'Elfen') {
return { type: 'minimum', value: 81, raceKey: 'Elves' }
} else if (race === 'Gnome') {
return { type: 'maximum', value: 80, raceKey: 'Gnomes' }
} else if (race === 'Zwerge') {
return { type: 'maximum', value: 80, raceKey: 'Dwarves' }
}
return null
}
},
created() {
// Initialize form with session data
if (this.sessionData.attributes && Object.keys(this.sessionData.attributes).length > 0) {
this.formData = { ...this.formData, ...this.sessionData.attributes }
}
this.updateTotal()
},
beforeUnmount() {
// Clean up timeout
if (this.overlayTimeout) {
clearTimeout(this.overlayTimeout)
}
},
methods: {
handleAttributeChange(event) {
// Simple update - Vue's reactivity should handle the rest
this.updateTotal()
},
updateTotal() {
this.totalPoints = Object.values(this.formData).reduce((sum, val) => sum + (val || 0), 0)
},
rollAttribute(attributeKey) {
let roll, rollValue, modifier = 0, rollDescription = ''
if (attributeKey === 'au') {
// Standard 1d100 roll for AU with race-based restrictions
roll = this.$rollNotation('1d100')
rollValue = roll.sum
// Apply race-based restrictions for AU (Aussehen)
const race = this.sessionData.rasse || ''
let minValue = 1, maxValue = 100, raceRestriction = ''
if (race === 'Elf') {
minValue = 81
raceRestriction = ' (Elfen minimum: 81)'
} else if (race === 'Gnom' || race === 'Zwerg') {
maxValue = 80
raceRestriction = ` (${race} maximum: 80)`
}
// Store original roll value for comparison
const originalRollValue = rollValue
// Apply race restrictions
if (rollValue < minValue) {
rollValue = minValue
} else if (rollValue > maxValue) {
rollValue = maxValue
}
roll = {
...roll,
selectedValue: rollValue
}
rollDescription = `1d100: ${roll.sum}${raceRestriction}`
if (rollValue !== originalRollValue) {
rollDescription += ` → adjusted to ${rollValue}`
}
} else {
// Standard max(2d100) roll for other attributes
roll = this.$rollNotation('max(2d100)')
rollValue = roll.selectedValue
rollDescription = `max of ${roll.rolls.join(', ')}`
}
const attributeName = this.attributes.find(attr => attr.key === attributeKey)?.name || attributeKey
this.formData[attributeKey] = rollValue
this.updateTotal()
// Store roll information for display
this.lastAttributeRoll = {
attribute: attributeKey,
attributeName: attributeName,
rolls: (attributeKey === 'au') ? [roll.sum] : (roll.rolls || [roll.sum]),
roll: rollValue,
result: rollValue,
description: rollDescription,
modifier: modifier,
isSpecialCalculation: attributeKey === 'au'
}
// Show overlay notification
this.showRollOverlay()
},
rollAllAttributes() {
// Roll all attributes at once
Object.keys(this.formData).forEach(key => {
this.rollAttribute(key)
})
this.updateTotal()
// Clear individual roll display when rolling all
this.lastAttributeRoll = null
},
showRollOverlay() {
this.showOverlay = true
// Clear existing timeout if any
if (this.overlayTimeout) {
clearTimeout(this.overlayTimeout)
}
// Hide overlay after 10 seconds (shorter for attributes)
this.overlayTimeout = setTimeout(() => {
this.showOverlay = false
}, 10000)
},
hideOverlay() {
this.showOverlay = false
if (this.overlayTimeout) {
clearTimeout(this.overlayTimeout)
this.overlayTimeout = null
}
},
handlePrevious() {
this.$emit('previous')
},
handleSubmit() {
if (this.isValid) {
this.$emit('next', { attributes: this.formData })
}
},
}
}
</script>
<style>
/* All common styles moved to main.css */
.instruction {
text-align: center;
margin-bottom: 20px;
color: #666;
font-style: italic;
flex-shrink: 0;
}
.attribute-description {
font-size: 11px;
color: #666;
font-style: italic;
display: block;
margin-top: 2px;
}
.race-restriction-warning {
font-size: 10px;
color: #ff5722;
font-weight: bold;
margin-top: 2px;
display: block;
}
.attribute-summary {
display: flex;
justify-content: center;
align-items: center;
gap: 30px;
margin-bottom: 20px;
padding: 15px;
background-color: #e3f2fd;
border-radius: 8px;
flex-shrink: 0;
flex-wrap: wrap;
}
.total-points, .average-points {
font-size: 18px;
color: #1976d2;
}
.roll-all-btn {
background-color: #ff9800;
color: white;
border: none;
border-radius: 4px;
padding: 10px 20px;
font-size: 14px;
cursor: pointer;
transition: background-color 0.3s;
}
.roll-all-btn:hover {
background-color: #f57c00;
}
.attributes-form-content {
display: flex;
flex-direction: column;
flex: 1;
}
</style>
@@ -1,573 +0,0 @@
<template>
<div class="basic-info-form character-creation-container">
<h2>{{ $t('characters.basicInfo.title') }}</h2>
<form @submit.prevent="handleSubmit">
<div class="form-row">
<!-- 1. Name -->
<div class="form-group">
<label for="name">{{ $t('characters.basicInfo.characterName') }} {{ $t('characters.basicInfo.required') }}
<span
class="help-icon"
:title="$t('characters.basicInfo.characterNameHelp')"
role="img"
:aria-label="$t('characters.basicInfo.characterNameHelp')"
>
?
</span>
</label>
<input
id="name"
v-model="formData.name"
type="text"
required
minlength="2"
maxlength="50"
:placeholder="$t('characters.basicInfo.characterNamePlaceholder')"
/>
</div>
<!-- 2. Herkunft -->
<div class="form-group">
<label for="herkunft">{{ $t('characters.basicInfo.origin') }} {{ $t('characters.basicInfo.required') }}</label>
<select id="herkunft" v-model="formData.herkunft" required>
<option value="">{{ $t('characters.basicInfo.selectOrigin') }}</option>
<option v-for="origin in origins" :key="origin" :value="origin">{{ origin }}</option>
</select>
</div>
</div>
<!-- 3. Glaube -->
<div class="form-group">
<label for="glaube">
{{ $t('characters.basicInfo.religion') }}
<span
class="help-icon"
:title="$t('characters.basicInfo.religionHelp')"
role="img"
:aria-label="$t('characters.basicInfo.religionHelp')"
>
?
</span>
</label>
<div class="belief-search">
<input
id="glaube"
v-model="beliefSearch"
type="text"
:placeholder="$t('characters.basicInfo.religionPlaceholder')"
@input="searchBeliefs"
/>
<div v-if="beliefResults.length > 0" class="belief-dropdown">
<div
v-for="belief in beliefResults"
:key="belief"
class="belief-option"
@click="selectBelief(belief)"
>
{{ belief }}
</div>
</div>
</div>
<div v-if="formData.glaube" class="selected-belief">
{{ $t('characters.basicInfo.selected') }}: {{ formData.glaube }}
<button type="button" @click="clearBelief" class="clear-btn">×</button>
</div>
</div>
<div class="form-row">
<!-- 4. Geschlecht -->
<div class="form-group">
<label for="geschlecht">{{ $t('characters.basicInfo.gender') }} {{ $t('characters.basicInfo.required') }}</label>
<select id="geschlecht" v-model="formData.geschlecht" required>
<option value="">{{ $t('characters.basicInfo.selectGender') }}</option>
<option value="Männlich">{{ $t('characters.basicInfo.male') }}</option>
<option value="Weiblich">{{ $t('characters.basicInfo.female') }}</option>
</select>
</div>
<!-- 5. Rasse -->
<div class="form-group">
<label for="rasse">{{ $t('characters.basicInfo.race') }} {{ $t('characters.basicInfo.required') }}</label>
<select id="rasse" v-model="formData.rasse" required>
<option value="">{{ $t('characters.basicInfo.selectRace') }}</option>
<option v-for="race in races" :key="race" :value="race">{{ race }}</option>
</select>
</div>
</div>
<div class="form-row">
<!-- 6. Charakterklasse -->
<div class="form-group">
<label for="typ">{{ $t('characters.basicInfo.characterClass') }} {{ $t('characters.basicInfo.required') }}</label>
<select id="typ" v-model="formData.typ" required>
<option value="">{{ $t('characters.basicInfo.selectClass') }}</option>
<option v-for="cls in classes" :key="cls" :value="cls">{{ cls }}</option>
</select>
</div>
<!-- 7. Sozialschicht -->
<div class="form-group">
<label for="stand">{{ $t('characters.basicInfo.socialClass') }} {{ $t('characters.basicInfo.required') }}</label>
<div class="input-with-dice">
<select id="stand" v-model="formData.stand" required>
<option value="">{{ $t('characters.basicInfo.selectSocialClass') }}</option>
<option value="Adel">{{ $t('characters.basicInfo.nobility') }}</option>
<option value="Mittelschicht">{{ $t('characters.basicInfo.middleClass') }}</option>
<option value="Volk">{{ $t('characters.basicInfo.commonFolk') }}</option>
<option value="Unfrei">{{ $t('characters.basicInfo.unfree') }}</option>
</select>
<button
type="button"
class="dice-btn"
@click="rollSocialClass"
:disabled="!formData.typ"
:title="formData.typ ? $t('characters.basicInfo.rollSocialClass') : $t('characters.basicInfo.selectClassFirst')"
>
🎲
</button>
</div>
<div v-if="lastSocialClassRoll" class="roll-result">
{{ $t('characters.basicInfo.rollResult') }}: {{ lastSocialClassRoll.roll }}
<span v-if="lastSocialClassRoll.modifier !== 0">
({{ lastSocialClassRoll.baseRoll }}{{ lastSocialClassRoll.modifier >= 0 ? '+' : '' }}{{ lastSocialClassRoll.modifier }})
</span>
{{ lastSocialClassRoll.result }}
</div>
</div>
</div>
<div class="form-actions">
<button type="submit" class="next-btn" :disabled="!isValid">
{{ $t('characters.basicInfo.nextAttributes') }}
</button>
</div>
</form>
<!-- Roll Result Overlay -->
<div v-if="showOverlay && lastSocialClassRoll" class="roll-overlay" @click="hideOverlay">
<div class="roll-overlay-content">
<button class="overlay-close" @click="hideOverlay">×</button>
<div class="overlay-title">🎲 {{ $t('characters.basicInfo.rollResult') }}</div>
<div class="overlay-roll">
{{ lastSocialClassRoll.roll }}
<span v-if="lastSocialClassRoll.modifier !== 0" class="roll-breakdown">
({{ lastSocialClassRoll.baseRoll }}{{ lastSocialClassRoll.modifier >= 0 ? '+' : '' }}{{ lastSocialClassRoll.modifier }})
</span>
</div>
<div class="overlay-result">
{{ $t('characters.basicInfo.' + lastSocialClassRoll.result.toLowerCase()) || lastSocialClassRoll.result }}
</div>
<div class="overlay-hint">{{ $t('characters.basicInfo.clickToClose') }}</div>
</div>
</div>
</div>
</template>
<script>
import API from '../../utils/api'
import { rollNotation } from '../../utils/randomUtils'
export default {
name: 'CharacterBasicInfo',
props: {
sessionData: {
type: Object,
required: true,
}
},
emits: ['next', 'save'],
data() {
return {
formData: {
name: '',
geschlecht: '',
rasse: '',
typ: '',
herkunft: '',
stand: '',
glaube: '',
},
races: [],
classes: [],
origins: [],
beliefSearch: '',
beliefResults: [],
searchTimeout: null,
lastSocialClassRoll: null,
showOverlay: false,
overlayTimeout: null,
isInitialized: false, // Flag to prevent early watcher triggers
}
},
computed: {
isValid() {
return this.formData.name.length >= 2 &&
this.formData.geschlecht &&
this.formData.rasse &&
this.formData.typ &&
this.formData.herkunft &&
this.formData.stand
}
},
watch: {
'formData.typ'() {
// Clear social class roll result when character class changes
this.lastSocialClassRoll = null
},
formData: {
handler(newValue) {
// Only save if component is fully initialized
if (this.isInitialized) {
this.$emit('save', { basic_info: newValue })
}
},
deep: true
}
},
async created() {
// Initialize form with session data - check both old format and new basic_info format
const basicInfo = this.sessionData.basic_info || {}
this.formData = {
name: basicInfo.name || this.sessionData.name || '',
geschlecht: basicInfo.geschlecht || this.sessionData.geschlecht || '',
rasse: basicInfo.rasse || this.sessionData.rasse || '',
typ: basicInfo.typ || this.sessionData.typ || '',
herkunft: basicInfo.herkunft || this.sessionData.herkunft || '',
stand: basicInfo.stand || this.sessionData.stand || '',
glaube: basicInfo.glaube || this.sessionData.glaube || '',
}
if (this.formData.glaube) {
this.beliefSearch = this.formData.glaube
}
// Save initial state to ensure all fields are captured
this.$emit('save', { basic_info: this.formData })
await this.loadReferenceData()
// Mark as initialized to enable watcher
this.isInitialized = true
},
beforeUnmount() {
// Clean up timeouts
if (this.searchTimeout) {
clearTimeout(this.searchTimeout)
}
if (this.overlayTimeout) {
clearTimeout(this.overlayTimeout)
}
},
methods: {
async loadReferenceData() {
try {
const token = localStorage.getItem('token')
const headers = { Authorization: `Bearer ${token}` }
// Load all reference data in parallel
const [racesRes, classesRes, originsRes] = await Promise.all([
API.get('/api/characters/races', { headers }),
API.get('/api/characters/classes', { headers }),
API.get('/api/characters/origins', { headers }),
])
this.races = racesRes.data.races
this.classes = classesRes.data.classes
this.origins = originsRes.data.origins
} catch (error) {
console.error('Error loading reference data:', error)
}
},
searchBeliefs() {
if (this.searchTimeout) {
clearTimeout(this.searchTimeout)
}
this.searchTimeout = setTimeout(async () => {
if (this.beliefSearch.length >= 2) {
try {
const token = localStorage.getItem('token')
const response = await API.get(`/api/characters/beliefs?q=${this.beliefSearch}`, {
headers: { Authorization: `Bearer ${token}` },
})
this.beliefResults = response.data.beliefs
} catch (error) {
console.error('Error searching beliefs:', error)
this.beliefResults = []
}
} else {
this.beliefResults = []
}
}, 300)
},
selectBelief(belief) {
this.formData.glaube = belief
this.beliefSearch = belief
this.beliefResults = []
},
clearBelief() {
this.formData.glaube = ''
this.beliefSearch = ''
this.beliefResults = []
},
rollSocialClass() {
if (!this.formData.typ) {
return
}
// Base 1d100 roll
const baseRoll = rollNotation('1d100')
let modifier = 0
// Apply class modifiers
switch (this.formData.typ) {
case 'Barde':
case 'Priester':
modifier = 20
break
case 'Druide':
case 'Magier':
modifier = 10
break
case 'Assassine':
case 'Händler':
case 'Waldläufer':
modifier = -10
break
case 'Spitzbube':
modifier = -20
break
}
const finalRoll = baseRoll.sum + modifier
// Determine social class based on final roll
let socialClass = ''
if (finalRoll <= 10) {
socialClass = 'Unfrei'
} else if (finalRoll <= 50) {
socialClass = 'Volk'
} else if (finalRoll <= 90) {
socialClass = 'Mittelschicht'
} else {
socialClass = 'Adel'
}
// Set the form data
this.formData.stand = socialClass
// Store roll information for display
this.lastSocialClassRoll = {
baseRoll: baseRoll.sum,
modifier: modifier,
roll: finalRoll,
result: socialClass
}
// Save the updated form data
this.$emit('save', { basic_info: this.formData })
// Show overlay notification
this.showRollOverlay()
},
showRollOverlay() {
this.showOverlay = true
// Clear existing timeout if any
if (this.overlayTimeout) {
clearTimeout(this.overlayTimeout)
}
// Hide overlay after 20 seconds
this.overlayTimeout = setTimeout(() => {
this.showOverlay = false
}, 20000)
},
hideOverlay() {
this.showOverlay = false
if (this.overlayTimeout) {
clearTimeout(this.overlayTimeout)
this.overlayTimeout = null
}
},
handleSubmit() {
if (this.isValid) {
// Save the current state before proceeding
this.$emit('save', { basic_info: this.formData })
this.$emit('next', { basic_info: this.formData })
}
},
}
}
</script>
<style>
/* All common styles moved to main.css */
.belief-search {
position: relative;
}
.belief-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
border: 1px solid #ddd;
border-top: none;
border-radius: 0 0 4px 4px;
max-height: 200px;
overflow-y: auto;
z-index: 1000;
}
.belief-option {
padding: 10px;
cursor: pointer;
border-bottom: 1px solid #eee;
}
.belief-option:hover {
background-color: #f5f5f5;
}
.belief-option:last-child {
border-bottom: none;
}
.selected-belief {
margin-top: 10px;
padding: 8px;
background-color: #e3f2fd;
border-radius: 4px;
display: flex;
justify-content: space-between;
align-items: center;
}
.clear-btn {
background: none;
border: none;
font-size: 18px;
cursor: pointer;
color: #666;
padding: 0;
width: 20px;
height: 20px;
}
.clear-btn:hover {
color: #f44336;
}
.roll-result {
margin-top: 8px;
padding: 8px;
background-color: #e8f5e8;
border-radius: 4px;
font-size: 14px;
color: #2e7d32;
}
.roll-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 10000;
cursor: pointer;
}
.roll-overlay-content {
background: white;
padding: 30px;
border-radius: 10px;
text-align: center;
position: relative;
cursor: default;
min-width: 300px;
}
.overlay-close {
position: absolute;
top: 10px;
right: 15px;
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #666;
}
.overlay-close:hover {
color: #000;
}
.overlay-title {
font-size: 20px;
font-weight: bold;
margin-bottom: 15px;
color: #333;
}
.overlay-roll {
font-size: 36px;
font-weight: bold;
color: #4caf50;
margin: 20px 0;
}
.roll-breakdown {
font-size: 18px;
color: #666;
margin-left: 10px;
}
.overlay-result {
font-size: 24px;
font-weight: bold;
color: #2196f3;
margin: 20px 0;
}
.overlay-hint {
font-size: 14px;
color: #666;
margin-top: 15px;
}
/*
.help-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
margin-left: 6px;
border: 1px solid #999;
border-radius: 50%;
font-size: 12px;
line-height: 1;
background: #f5f5f5;
color: #555;
}
.help-icon:hover {
background: #e0e0e0;
color: #222;
}
*/
</style>
@@ -1,486 +0,0 @@
<template>
<div class="derived-values-form character-creation-container">
<h2>{{ $t('characters.derivedValues.title') }}</h2>
<p class="instruction">{{ $t('characters.derivedValues.instruction') }}</p>
<form @submit.prevent="handleSubmit">
<div class="values-grid">
<div class="value-group" v-for="value in derivedValues" :key="value.key">
<label :for="value.key">{{ $t(value.name) }}</label>
<div class="value-input-group">
<div class="input-with-dice">
<input
:id="value.key"
v-model.number="formData[value.key]"
type="number"
:min="value.min"
:max="value.max"
required
/>
<button
v-if="value.key === 'pa' || value.key === 'wk' || value.key === 'lp_max' || value.key === 'ap_max' || value.key === 'b_max'"
type="button"
class="dice-btn"
@click="rollField(value.key)"
:title="getDiceTooltip(value.key)"
:disabled="isCalculating"
>
{{ isCalculating ? '⏳' : '🎲' }}
</button>
</div>
<div class="value-info">
<span class="calculated-value">{{ $t('characters.derivedValues.calculated') }}: {{ calculatedValues[value.key] }}</span>
<span class="value-description">{{ $t(value.description) }}</span>
</div>
</div>
</div>
</div>
<div class="calculation-info">
<h3>{{ $t('characters.derivedValues.calculationRules') }}</h3>
<div class="calculation-rules">
<div class="rule">
<strong>{{ $t('characters.derivedValues.lpFormula') }}:</strong> {{ $t('characters.derivedValues.lpDescription') }}
</div>
<div class="rule">
<strong>{{ $t('characters.derivedValues.apFormula') }}:</strong> {{ $t('characters.derivedValues.apDescription') }}
</div>
<div class="rule">
<strong>{{ $t('characters.derivedValues.bFormula') }}:</strong> {{ $t('characters.derivedValues.bDescription') }}
</div>
<div class="rule">
<strong>{{ $t('characters.derivedValues.benniesFormula') }}:</strong> {{ $t('characters.derivedValues.benniesDescription') }}
</div>
</div>
</div>
<div class="form-actions">
<button type="button" @click="handlePrevious" class="prev-btn">
{{ $t('characters.derivedValues.previousAttributes') }}
</button>
<button type="button" @click="calculateAllStatic" class="calc-btn" :disabled="isCalculating">
{{ isCalculating ? $t('characters.derivedValues.calculating') : $t('characters.derivedValues.recalculate') }}
</button>
<button type="submit" class="next-btn" :disabled="!isValid">
{{ $t('characters.derivedValues.nextSkills') }}
</button>
</div>
</form>
</div>
</template>
<script>
import API from '../../utils/api'
import { rollDie, rollDice } from '../../utils/randomUtils'
export default {
name: 'CharacterDerivedValues',
props: {
sessionData: {
type: Object,
required: true,
}
},
emits: ['next', 'previous', 'save'],
data() {
return {
formData: {
pa: 0, // Persönliche Ausstrahlung
wk: 0, // Willenskraft
lp_max: 0,
ap_max: 0,
b_max: 0,
resistenz_koerper: 0, // Resistenz Körper
resistenz_geist: 0, // Resistenz Geist
resistenz_bonus_koerper: 0, // Resistenz Bonus Körper
resistenz_bonus_geist: 0, // Resistenz Bonus Geist
abwehr: 0, // Abwehr
abwehr_bonus: 0, // Abwehr Bonus
ausdauer_bonus: 0, // Ausdauer Bonus
angriffs_bonus: 0, // Angriffs Bonus
zaubern: 0, // Zaubern
zauber_bonus: 0, // Zauber Bonus
raufen: 0, // Raufen
schadens_bonus: 0, // Schadens Bonus
sg: 0, // Schicksalsgunst
gg: 0, // Göttliche Gnade
gp: 0, // Glückspunkte
},
isCalculating: false,
derivedValues: [
{
key: 'pa',
name: 'characters.derivedValues.pa',
description: 'characters.derivedValues.paDescription',
min: 1,
max: 100
},
{
key: 'wk',
name: 'characters.derivedValues.wk',
description: 'characters.derivedValues.wkDescription',
min: 1,
max: 100
},
{
key: 'lp_max',
name: 'characters.derivedValues.lpMax',
description: 'characters.derivedValues.lpMaxDescription',
min: 1,
max: 50
},
{
key: 'ap_max',
name: 'characters.derivedValues.apMax',
description: 'characters.derivedValues.apMaxDescription',
min: 1,
max: 200
},
{
key: 'b_max',
name: 'characters.derivedValues.bMax',
description: 'characters.derivedValues.bMaxDescription',
min: 1,
max: 50
},
{
key: 'resistenz_koerper',
name: 'characters.derivedValues.resistenzKoerper',
description: 'characters.derivedValues.resistenzKoerperDescription',
min: 1,
max: 20
},
{
key: 'resistenz_geist',
name: 'characters.derivedValues.resistenzGeist',
description: 'characters.derivedValues.resistenzGeistDescription',
min: 1,
max: 20
},
{
key: 'resistenz_bonus_koerper',
name: 'characters.derivedValues.resistenzBonusKoerper',
description: 'characters.derivedValues.resistenzBonusKoerperDescription',
min: -5,
max: 5
},
{
key: 'resistenz_bonus_geist',
name: 'characters.derivedValues.resistenzBonusGeist',
description: 'characters.derivedValues.resistenzBonusGeistDescription',
min: -5,
max: 5
},
{
key: 'abwehr',
name: 'characters.derivedValues.abwehr',
description: 'characters.derivedValues.abwehrDescription',
min: 1,
max: 20
},
{
key: 'abwehr_bonus',
name: 'characters.derivedValues.abwehrBonus',
description: 'characters.derivedValues.abwehrBonusDescription',
min: -5,
max: 5
},
{
key: 'ausdauer_bonus',
name: 'characters.derivedValues.ausdauerBonus',
description: 'characters.derivedValues.ausdauerBonusDescription',
min: -50,
max: 50
},
{
key: 'angriffs_bonus',
name: 'characters.derivedValues.angriffsBonus',
description: 'characters.derivedValues.angriffsBonusDescription',
min: -5,
max: 5
},
{
key: 'zaubern',
name: 'characters.derivedValues.zaubern',
description: 'characters.derivedValues.zaubernDescription',
min: 1,
max: 20
},
{
key: 'zauber_bonus',
name: 'characters.derivedValues.zauberBonus',
description: 'characters.derivedValues.zauberBonusDescription',
min: -5,
max: 5
},
{
key: 'raufen',
name: 'characters.derivedValues.raufen',
description: 'characters.derivedValues.raufenDescription',
min: 1,
max: 20
},
{
key: 'schadens_bonus',
name: 'characters.derivedValues.schadensBonus',
description: 'characters.derivedValues.schadensBonusDescription',
min: -10,
max: 10
},
{
key: 'sg',
name: 'characters.derivedValues.sg',
description: 'characters.derivedValues.sgDescription',
min: 0,
max: 50
},
{
key: 'gg',
name: 'characters.derivedValues.gg',
description: 'characters.derivedValues.ggDescription',
min: 0,
max: 50
},
{
key: 'gp',
name: 'characters.derivedValues.gp',
description: 'characters.derivedValues.gpDescription',
min: 0,
max: 50
},
],
}
},
computed: {
isValid() {
return Object.entries(this.formData).every(([key, val]) => {
const valueConfig = this.derivedValues.find(v => v.key === key)
return val >= valueConfig.min && val <= valueConfig.max
})
},
calculatedValues() {
// Return currently loaded values or defaults
// The actual calculation happens via API calls
return this.formData
}
},
watch: {
formData: {
handler(newValue) {
// Save changes automatically when form data changes
this.$emit('save', { derived_values: newValue })
},
deep: true
}
},
created() {
// Initialize with existing session data if available
if (this.sessionData.derived_values && Object.keys(this.sessionData.derived_values).length > 0) {
this.formData = { ...this.formData, ...this.sessionData.derived_values }
} else {
// Calculate initial values using new API
this.calculateAllStatic()
}
},
methods: {
async calculateAllStatic() {
if (this.isCalculating) return
this.isCalculating = true
try {
const attrs = this.sessionData.attributes || {}
const basic = this.sessionData.basic_info || {}
const token = localStorage.getItem('token')
const response = await API.post('/api/characters/calculate-static-fields', {
st: attrs.st || 0,
gs: attrs.gs || 0,
gw: attrs.gw || 0,
ko: attrs.ko || 0,
in: attrs.in || 0,
zt: attrs.zt || 0,
au: attrs.au || 0,
rasse: basic.rasse || 'Menschen',
typ: basic.typ || 'Barbar'
}, {
headers: { Authorization: `Bearer ${token}` }
})
const staticValues = response.data
// Update form data with calculated static values
this.formData = {
...this.formData,
ausdauer_bonus: staticValues.ausdauer_bonus,
schadens_bonus: staticValues.schadens_bonus,
angriffs_bonus: staticValues.angriffs_bonus,
abwehr_bonus: staticValues.abwehr_bonus,
zauber_bonus: staticValues.zauber_bonus,
resistenz_bonus_koerper: staticValues.resistenz_bonus_koerper,
resistenz_bonus_geist: staticValues.resistenz_bonus_geist,
resistenz_koerper: staticValues.resistenz_koerper,
resistenz_geist: staticValues.resistenz_geist,
abwehr: staticValues.abwehr,
zaubern: staticValues.zaubern,
raufen: staticValues.raufen
}
// Save the updated values to session
this.$emit('save', { derived_values: this.formData })
} catch (error) {
console.error('Error calculating static values:', error)
} finally {
this.isCalculating = false
}
},
async rollField(fieldName) {
if (this.isCalculating) return
this.isCalculating = true
try {
const attrs = this.sessionData.attributes || {}
const basic = this.sessionData.basic_info || {}
const token = localStorage.getItem('token')
// Generate dice roll based on field type
let roll
switch (fieldName) {
case 'pa':
case 'wk':
roll = rollDie(100) // 1d100
break
case 'lp_max':
roll = rollDie(3) // 1d3 - single number
break
case 'ap_max':
roll = rollDie(3) // 1d3 - array of 3 values
break
case 'b_max':
// B Max depends on race: Gnome/Halblinge=2d3, Zwerge=3d3, others=4d3
let diceCount = 4 // default for most races
if (basic.rasse === 'Gnome' || basic.rasse === 'Halblinge') {
diceCount = 2
} else if (basic.rasse === 'Zwerge') {
diceCount = 3
}
roll = rollDice(diceCount, 3) // XdY where X depends on race, Y=3
break
}
const response = await API.post('/api/characters/calculate-rolled-field', {
st: attrs.st || 0,
gs: attrs.gs || 0,
gw: attrs.gw || 0,
ko: attrs.ko || 0,
in: attrs.in || 0,
zt: attrs.zt || 0,
au: attrs.au || 0,
rasse: basic.rasse || 'Menschen',
typ: basic.typ || 'Barbar',
field: fieldName,
roll: roll
}, {
headers: { Authorization: `Bearer ${token}` }
})
const result = response.data
this.formData[fieldName] = result.value
// Save the updated values to session
this.$emit('save', { derived_values: this.formData })
} catch (error) {
console.error('Error calculating rolled field:', error)
} finally {
this.isCalculating = false
}
},
getDiceTooltip(fieldName) {
switch (fieldName) {
case 'pa':
return this.$t('characters.derivedValues.paRollTooltip')
case 'wk':
return this.$t('characters.derivedValues.wkRollTooltip')
case 'lp_max':
return this.$t('characters.derivedValues.lpRollTooltip')
case 'ap_max':
return this.$t('characters.derivedValues.apRollTooltip')
case 'b_max':
return this.$t('characters.derivedValues.bRollTooltip')
default:
return ''
}
},
// Legacy methods for backward compatibility
calculateLP(constitution) {
// LP = 1d3 + 7 + (Ko/10)
const diceRoll = Math.floor(Math.random() * 3) + 1 // 1d3
const constitutionBonus = Math.floor(constitution / 10)
const result = diceRoll + 7 + constitutionBonus
return result
},
calculatePA(intelligence) {
// PA = 1d100 + 4×(In/10) - 20
const baseRoll = Math.floor(Math.random() * 100) + 1
const intelligenceBonus = Math.floor(intelligence / 10) * 4
const result = baseRoll + intelligenceBonus - 20
return Math.max(1, Math.min(100, result))
},
calculateWK(constitution, intelligence) {
// WK = 1d100 + 2×(Ko/10 + In/10) - 20
const baseRoll = Math.floor(Math.random() * 100) + 1
const constitutionBonus = Math.floor(constitution / 10)
const intelligenceBonus = Math.floor(intelligence / 10)
const combinedBonus = (constitutionBonus + intelligenceBonus) * 2
const result = baseRoll + combinedBonus - 20
return Math.max(1, Math.min(100, result))
},
rollLP() {
this.rollField('lp_max')
},
rollPA() {
this.rollField('pa')
},
rollWK() {
this.rollField('wk')
},
getClassBonnie(type) {
// TODO: Implement class-specific bonnie calculations
// For now, return base values
const bonnieMap = {
'sg': 0,
'gg': 0,
'gp': 0,
}
return bonnieMap[type] || 1
},
recalculate() {
this.calculateAllStatic()
},
handlePrevious() {
this.$emit('previous')
},
handleSubmit() {
if (this.isValid) {
this.$emit('next', { derived_values: this.formData })
}
},
}
}
</script>
<style>
/* All common styles moved to main.css */
</style>
@@ -1,774 +0,0 @@
<template>
<div class="fullwidth-page">
<div class="page-header">
<h2>Fertigkeiten & Lernpunkte</h2>
</div>
<p style="color: #666; margin-bottom: 30px; font-size: 16px; line-height: 1.5;">
Wählen Sie Fertigkeiten für Ihren {{ characterClass }}-Charakter aus.
Jede Kategorie hat eine begrenzte Anzahl von Lernpunkten.
</p>
<!-- Loading State -->
<div v-if="isLoading" class="loading-message">
<div class="loading-spinner"></div>
<p>Lade Lernpunkte für {{ characterClass }}...</p>
</div>
<!-- Error State -->
<div v-else-if="error" class="error-message">
{{ error }}
<br>
<button @click="retry" class="btn btn-primary" style="margin-top: 10px;">Erneut versuchen</button>
</div>
<!-- Main Content -->
<div v-else class="skills-content">
<!-- Three Column Layout -->
<div class="three-column-grid">
<!-- Left Column: Available Skills for Selected Category -->
<div>
<div v-if="selectedCategory" class="skills-content">
<div class="list-container">
<div class="section-header" style="padding: 20px 20px 10px;">
<h3>{{ getSelectedCategoryName() }} - Verfügbare Fertigkeiten</h3>
</div>
<!-- Loading skills -->
<div v-if="isLoadingSkills" class="loading-message">
<div class="loading-spinner"></div>
<p>Lade Fertigkeiten...</p>
</div>
<!-- Available skills -->
<div v-else-if="availableSkillsForSelectedCategory.length > 0">
<div
v-for="skill in availableSkillsForSelectedCategory"
:key="skill.name"
class="list-item"
:class="{ 'opacity-50': !canAffordSkillInCategory(skill) }"
>
<div class="list-item-content">
<div class="list-item-title">{{ skill.name }}</div>
<div class="list-item-details">
<span class="badge badge-primary">{{ skill.cost }} LE</span>
<span v-if="skill.attribute" class="badge badge-secondary">({{ skill.attribute }})</span>
</div>
</div>
<div class="list-item-actions">
<button
@click="selectSkillForLearning(skill)"
:disabled="!canAffordSkillInCategory(skill)"
class="btn btn-primary"
style="font-size: 12px;"
>
Hinzufügen
</button>
</div>
</div>
</div>
<!-- No skills found -->
<div v-else class="empty-state">
<p>Keine Fertigkeiten für diese Kategorie gefunden.</p>
</div>
</div>
</div>
<!-- No category selected -->
<div v-else-if="learningCategories.length > 0" class="empty-state">
<p>Wählen Sie eine Lernpunkte-Kategorie aus, um verfügbare Fertigkeiten zu sehen.</p>
</div>
</div>
<!-- Center Column: Selected Skills -->
<div>
<div class="list-container">
<div class="section-header" style="padding: 20px 20px 10px;">
<h3>Gewählte Fertigkeiten</h3>
</div>
<div v-if="selectedSkills.length > 0">
<div
v-for="skill in selectedSkills"
:key="skill.name"
class="list-item"
>
<div class="list-item-content">
<div class="list-item-title">{{ skill.name }}</div>
<div class="list-item-details">
<span class="badge badge-primary">{{ skill.cost }} LE</span>
<span class="badge badge-info">{{ skill.category }}</span>
</div>
</div>
<div class="list-item-actions">
<button
@click="removeSkill(skill)"
class="btn btn-danger"
style="width: 30px; height: 30px; border-radius: 50%; padding: 0;"
title="Fertigkeit entfernen"
>
×
</button>
</div>
</div>
<!-- Summary -->
<div class="card" style="margin: 15px 20px;">
<div class="resource-card" style="justify-content: center;">
<div class="resource-info" style="text-align: center;">
<div class="resource-label">Gesamt verbrauchte LE:</div>
<div class="resource-amount">{{ totalUsedPoints }}</div>
</div>
</div>
</div>
</div>
<div v-else class="empty-state">
<p>Noch keine Fertigkeiten gewählt.</p>
<p style="font-style: italic; margin-top: 10px; font-size: 14px; color: #6c757d;">Wählen Sie eine Kategorie und fügen Sie Fertigkeiten hinzu.</p>
</div>
</div>
</div>
<!-- Right Column: Learning Points + Typical Skills -->
<div>
<!-- Learning Points Overview -->
<div v-if="learningCategories.length > 0" style="margin-bottom: 30px;">
<div class="section-header">
<h3>Verfügbare Lernpunkte</h3>
</div>
<div style="display: flex; flex-direction: column; gap: 15px;">
<div
v-for="category in learningCategories"
:key="category.name"
class="card"
:class="{
'border-primary': selectedCategory === category.name,
'border-danger': category.remainingPoints === 0,
'border-warning': category.remainingPoints < category.totalPoints && category.remainingPoints > 0
}"
@click="selectCategory(category.name)"
style="cursor: pointer;"
>
<div class="list-item-title">{{ category.displayName }}</div>
<div class="resource-display">
<div class="resource-card" style="justify-content: center; text-align: center;">
<div class="resource-info">
<div class="resource-amount">
<span style="color: #28a745; font-size: 16px;">{{ category.remainingPoints }}</span>
<span style="color: #6c757d;">/</span>
<span style="color: #6c757d;">{{ category.totalPoints }}</span>
<span style="color: #6c757d; font-size: 12px;">LE</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Typical Skills Info -->
<div v-if="typicalSkills.length > 0" class="card">
<div class="section-header">
<h3>Empfohlene Fertigkeiten für {{ characterClass }}</h3>
</div>
<p style="color: #6c757d; margin-bottom: 15px;">
Die folgenden Fertigkeiten werden häufig von Charakteren Ihrer Klasse erlernt:
</p>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
<span
v-for="skill in typicalSkills"
:key="skill.name"
class="badge badge-info"
:title="`${skill.name} (${skill.attribute}) - Bonus: +${skill.bonus}`"
>
{{ skill.name }} ({{ skill.attribute }})
</span>
</div>
</div>
</div>
</div>
</div>
<!-- Navigation -->
<div class="form-row" style="justify-content: space-between; padding-top: 20px; border-top: 1px solid #dee2e6; margin-top: 30px;">
<button type="button" @click="handlePrevious" class="btn btn-secondary">
Zurück: Abgeleitete Werte
</button>
<button
type="button"
@click="handleNext"
class="btn btn-primary"
:disabled="!canProceed"
>
Weiter: Zauber
</button>
</div>
<!-- Debug Info (removable) -->
<div v-if="showDebug || error" class="card" style="margin-top: 20px; font-family: monospace; font-size: 12px;">
<div class="section-header">
<h4>Debug Information</h4>
<button @click="showDebug = !showDebug" class="btn btn-sm">{{ showDebug ? 'Hide' : 'Show' }} Debug</button>
</div>
<div v-if="showDebug || error">
<pre style="margin: 0; white-space: pre-wrap; word-break: break-all;">{{ debugInfo }}</pre>
</div>
</div>
</div>
</template>
<script>
import API from '../../utils/api'
export default {
name: 'CharacterSkills',
props: {
sessionData: {
type: Object,
required: true,
}
},
emits: ['next', 'previous', 'save'],
data() {
return {
// Loading states
isLoading: false,
isLoadingSkills: false,
error: null,
// Learning data
learningPointsData: null,
learningCategories: [],
typicalSkills: [],
// Skills selection
selectedCategory: null,
availableSkills: [],
selectedSkills: [],
// Available skills fetching
availableSkillsByCategory: null,
// Debug
showDebug: false // Set to true for debugging
}
},
computed: {
// Get character class from session data (direct access like in working version)
characterClass() {
return this.sessionData?.typ || ''
},
// Get character stand from session data (direct access like in working version)
characterStand() {
return this.sessionData?.stand || ''
},
totalUsedPoints() {
return this.selectedSkills.reduce((sum, skill) => sum + (skill.cost || 0), 0)
},
canProceed() {
// Allow proceeding even without skills selected - this is optional
return true
},
debugInfo() {
return {
// Session Data
hasSessionData: !!this.sessionData,
sessionDataKeys: this.sessionData ? Object.keys(this.sessionData) : null,
basicInfo: this.sessionData?.basic_info || null,
// Character Info
characterClass: this.characterClass,
characterStand: this.characterStand,
// Loading States
isLoading: this.isLoading,
isLoadingSkills: this.isLoadingSkills,
error: this.error,
// Data States
hasLearningPointsData: !!this.learningPointsData,
learningCategories: this.learningCategories.length,
hasAvailableSkills: !!this.availableSkillsByCategory,
selectedSkillsCount: this.selectedSkills.length,
selectedCategory: this.selectedCategory,
totalUsedPoints: this.totalUsedPoints,
// Raw Data (for debugging)
learningPointsData: this.learningPointsData,
availableSkillsByCategory: this.availableSkillsByCategory
}
},
// Skills for the selected category (from Learning Points section)
availableSkillsForSelectedCategory() {
if (!this.selectedCategory || !this.availableSkillsByCategory) {
return []
}
// Try to find the matching category key
const categoryKey = this.findCategoryKey(this.selectedCategory)
if (!categoryKey) {
return []
}
// Get skills from the selected category
const categorySkills = this.availableSkillsByCategory[categoryKey] || []
const filteredSkills = categorySkills.map(skill => ({
...skill,
cost: this.getSkillCost(skill),
category: categoryKey // Use the actual category key from availableSkillsByCategory
}))
.filter(skill => {
// Remove already selected skills
const selectedSkillNames = this.selectedSkills.map(s => s.name)
return !selectedSkillNames.includes(skill.name)
})
.filter(skill => this.canAffordSkillInCategory(skill))
.sort((a, b) => {
const aLeCost = this.getSkillCost(a)
const bLeCost = this.getSkillCost(b)
// First sort by LE cost (ascending)
if (aLeCost !== bLeCost) {
return aLeCost - bLeCost
}
// If costs are equal, sort alphabetically
return a.name.localeCompare(b.name)
})
return filteredSkills
},
totalSelectedEP() {
return this.selectedSkills.reduce((total, skill) => total + this.getSkillCost(skill), 0)
},
totalSelectedGold() {
// For character creation, we only track learning costs, not gold costs
return 0
}
},
watch: {
selectedSkills: {
handler(newSkills) {
// Save skills automatically when they change
this.saveSkillsToSession()
},
deep: true
}
},
created() {
// Initialize with existing session data if available
if (this.sessionData.skills && Array.isArray(this.sessionData.skills)) {
this.selectedSkills = [...this.sessionData.skills]
}
// Restore selected category if available
if (this.sessionData.skills_meta?.selectedCategory) {
this.selectedCategory = this.sessionData.skills_meta.selectedCategory
}
// Initialize component
this.initializeComponent()
},
beforeUnmount() {
// Ensure skills are saved when component is about to be unmounted
this.saveSkillsToSession()
},
methods: {
saveSkillsToSession() {
// Save skills to session data via emit
this.$emit('save', {
skills: this.selectedSkills,
skills_meta: {
totalUsedPoints: this.totalUsedPoints,
selectedCategory: this.selectedCategory
}
})
},
async initializeComponent() {
try {
this.isLoading = true
this.error = null
// Load learning points from backend
await this.loadLearningPoints()
// Load available skills
await this.loadAvailableSkills()
// Restore previously selected skills if any
this.restoreSelectedSkills()
// Update points based on selected skills
this.updateRemainingPoints()
} catch (error) {
console.error('Error initializing component:', error)
this.error = 'Fehler beim Laden der Lernpunkte. Bitte versuchen Sie es erneut.'
} finally {
this.isLoading = false
}
},
async loadLearningPoints() {
if (!this.characterClass) {
throw new Error('Charakterklasse nicht verfügbar')
}
const params = {
class: this.characterClass
}
if (this.characterStand) {
params.stand = this.characterStand
}
const response = await API.get('/api/characters/classes/learning-points', { params })
this.learningPointsData = response.data
// Process learning points into categories
this.processLearningPoints()
// Store typical skills
this.typicalSkills = this.learningPointsData.typical_skills || []
},
processLearningPoints() {
const learningPoints = this.learningPointsData.learning_points || {}
this.learningCategories = Object.entries(learningPoints).map(([categoryKey, points]) => ({
name: categoryKey.toLowerCase(),
displayName: categoryKey,
totalPoints: points,
remainingPoints: points
}))
// Add weapon points as separate category if available
if (this.learningPointsData.weapon_points && this.learningPointsData.weapon_points > 0) {
console.log('Adding weapon category with points:', this.learningPointsData.weapon_points)
this.learningCategories.push({
name: 'waffen',
displayName: 'Waffenfertigkeiten',
totalPoints: this.learningPointsData.weapon_points,
remainingPoints: this.learningPointsData.weapon_points
})
} else {
console.log('No weapon points found:', this.learningPointsData.weapon_points)
}
},
restoreSelectedSkills() {
if (this.sessionData.skills && Array.isArray(this.sessionData.skills)) {
// Simply restore skills as they are
this.selectedSkills = this.sessionData.skills.map(skill => {
return { ...skill }
})
console.log('Restored selected skills:', this.selectedSkills)
}
},
updateRemainingPoints() {
// Reset all categories to full points
this.learningCategories.forEach(category => {
category.remainingPoints = category.totalPoints
})
// Deduct points for selected skills
this.selectedSkills.forEach(skill => {
const category = this.learningCategories.find(cat =>
cat.name === skill.category?.toLowerCase()
)
if (category && skill.cost) {
category.remainingPoints = Math.max(0, category.remainingPoints - this.getSkillCost(skill))
}
})
},
async selectCategory(categoryName) {
this.selectedCategory = categoryName
// Skills are now provided by computed property availableSkillsForSelectedCategory
},
getSelectedCategoryName() {
const category = this.learningCategories.find(cat => cat.name === this.selectedCategory)
return category ? category.displayName : this.selectedCategory
},
findCategoryKey(selectedCategoryName) {
if (!this.availableSkillsByCategory) return null
// Try to find by learning category mapping first (most likely scenario)
const learningCategory = this.learningCategories?.find(cat => cat.name === selectedCategoryName)
if (learningCategory && this.availableSkillsByCategory[learningCategory.displayName]) {
return learningCategory.displayName
}
// Try direct match
if (this.availableSkillsByCategory[selectedCategoryName]) {
return selectedCategoryName
}
// Try case-insensitive search
const availableKeys = Object.keys(this.availableSkillsByCategory)
const foundKey = availableKeys.find(key =>
key.toLowerCase() === selectedCategoryName.toLowerCase()
)
return foundKey || null
},
getSkillCost(skill) {
// Unified method to get skill cost from various possible properties
return skill.cost || skill.learnCost || skill.leCost || 0
},
canAffordSkillInCategory(skill) {
// Check if character has enough learning points in the skill's category
// Handle both displayName format (e.g., "Sozial") and internal name format (e.g., "sozial")
let category = this.learningCategories.find(cat =>
cat.displayName === skill.category
)
// If not found by displayName, try by internal name
if (!category) {
category = this.learningCategories.find(cat =>
cat.name === skill.category?.toLowerCase()
)
}
if (!category) {
console.warn('No learning category found for skill:', skill.name, 'category:', skill.category)
return false
}
const skillCost = this.getSkillCost(skill)
return category.remainingPoints >= skillCost
},
removeSkill(skill) {
const index = this.selectedSkills.findIndex(s => s.name === skill.name)
if (index >= 0) {
this.selectedSkills.splice(index, 1)
this.updateRemainingPoints()
console.log('Removed skill:', skill.name, 'Selected skills:', this.selectedSkills)
}
},
async retry() {
await this.initializeComponent()
},
handlePrevious() {
this.$emit('previous')
},
handleNext() {
const data = {
skills: this.selectedSkills,
skill_points: this.learningCategories.reduce((acc, cat) => {
acc[cat.name] = cat.remainingPoints
return acc
}, {}),
learning_points_data: this.learningPointsData
}
console.log('Proceeding with data:', data)
this.$emit('next', data)
},
// Skills methods
async loadAvailableSkills() {
if (!this.characterClass) {
return
}
this.isLoadingSkills = true
try {
const requestData = {
characterClass: this.characterClass
}
const response = await API.post('/api/characters/available-skills-creation', requestData)
if (response.data && response.data.skills_by_category) {
this.availableSkillsByCategory = response.data.skills_by_category
} else {
this.generateSampleSkills()
}
} catch (error) {
console.error('Error loading skills:', error)
this.generateSampleSkills()
} finally {
this.isLoadingSkills = false
}
},
generateSampleSkills() {
// Fallback for testing
this.availableSkillsByCategory = {
'Körperliche Fertigkeiten': [
{ name: 'Klettern', learnCost: 50 },
{ name: 'Schwimmen', learnCost: 40 },
{ name: 'Springen', learnCost: 30 }
],
'Geistige Fertigkeiten': [
{ name: 'Erste Hilfe', learnCost: 60 },
{ name: 'Naturkunde', learnCost: 75 },
{ name: 'Menschenkenntnis', learnCost: 65 }
],
'Handwerkliche Fertigkeiten': [
{ name: 'Bogenbau', learnCost: 100 },
{ name: 'Schmieden', learnCost: 125 }
]
}
console.log('Generated sample skills:', this.availableSkillsByCategory)
},
isSkillSelected(skill) {
return this.selectedSkills.some(s => s.name === skill.name)
},
selectSkillForLearning(skill) {
if (this.isSkillSelected(skill)) {
return // Already selected
}
// Check if the skill can be afforded
if (!this.canAffordSkillInCategory(skill)) {
console.log('Cannot afford skill:', skill.name)
return
}
// Add skill to selected list with proper cost and category
const skillToAdd = {
...skill,
cost: this.getSkillCost(skill), // Ensure cost is properly set
category: skill.category // Use the category as-is
}
this.selectedSkills.push(skillToAdd)
// Update remaining points for all categories
this.updateRemainingPoints()
console.log('Skill selected for learning:', skill.name, 'Cost:', skillToAdd.cost)
console.log('Updated remaining points')
},
removeSkillFromSelection(skill) {
const index = this.selectedSkills.findIndex(s => s.name === skill.name)
if (index !== -1) {
this.selectedSkills.splice(index, 1)
// Update remaining points for all categories after removal
this.updateRemainingPoints()
console.log('Skill removed from selection:', skill.name)
console.log('Updated remaining points after removal')
}
},
// Navigation methods
handlePrevious() {
this.$emit('previous')
},
handleNext() {
// Save current skills before navigating
this.$emit('next', {
skills: this.selectedSkills,
skills_meta: {
totalUsedPoints: this.totalUsedPoints,
selectedCategory: this.selectedCategory
}
})
},
retry() {
this.error = null
this.initializeComponent()
}
}
}
</script>
<style scoped>
/* All common styles moved to main.css */
.fullwidth-page {
/*
padding: 0 !important;
margin: 0 !important;
width: 100vw !important;
max-width: 100vw !important;
box-sizing: border-box !important;
*/
}
.page-header {
padding: 15px 20px;
margin-bottom: 20px;
}
.three-column-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 30px;
margin: 0 20px 30px 20px;
width: calc(100vw - 40px);
max-width: calc(100vw - 40px);
box-sizing: border-box;
}
.skills-content {
width: 100%;
max-width: 100%;
}
.opacity-50 {
opacity: 0.6;
}
.border-primary {
border-color: #007bff !important;
background-color: #f8fcff;
}
.border-warning {
border-color: #ffc107 !important;
background-color: #fff8e1;
}
.border-danger {
border-color: #dc3545 !important;
background-color: #ffebee;
}
@media (max-width: 1200px) {
.three-column-grid {
grid-template-columns: 1fr 1fr;
gap: 20px;
}
}
@media (max-width: 768px) {
.three-column-grid {
grid-template-columns: 1fr;
gap: 15px;
}
}
</style>
@@ -1,982 +0,0 @@
<template>
<div class="fullwidth-page">
<div class="page-header">
<h2>{{ $t('characters.create.spells.title') }}</h2>
</div>
<p style="color: #666; margin-bottom: 30px; font-size: 16px; line-height: 1.5;">
{{ $t('characters.create.spells.description') }}
</p>
<!-- Spell Points Display -->
<div v-if="spellPoints.total > 0" class="spell-points-display">
<div class="points-header">
<h4>Zauber-Lernpunkte</h4>
</div>
<div class="points-info">
<span class="points-remaining">{{ spellPoints.remaining }}</span>
<span class="points-separator">/</span>
<span class="points-total">{{ spellPoints.total }}</span>
<span class="points-label">verfügbare LE</span>
</div>
<div class="points-usage" v-if="totalSelectedLE > 0">
<span class="usage-label">Verwendet:</span>
<span class="usage-value">{{ totalSelectedLE }} LE</span>
</div>
</div>
<!-- Loading State -->
<div v-if="isLoading" class="loading-message">
<div class="loading-spinner"></div>
<p>Lade Zauber für {{ characterClass }}...</p>
</div>
<!-- Error State -->
<div v-else-if="error" class="error-message">
{{ error }}
<br>
<button @click="retry" class="btn btn-primary" style="margin-top: 10px;">Erneut versuchen</button>
</div>
<!-- Main Content -->
<div v-else class="spells-content">
<!-- Three Column Layout -->
<div class="three-column-grid">
<!-- Left Column: Available Spells for Selected Category -->
<div class="card">
<div class="section-header">
<h4>Verfügbare Zauber</h4>
<!--<span v-if="selectedCategory" class="category-badge">{{ getSelectedCategoryName() }}</span>-->
</div>
<div v-if="!selectedCategory" class="no-selection-message">
<p>Wählen Sie eine Kategorie aus den Zauberarten rechts, um verfügbare Zauber zu sehen.</p>
</div>
<div v-else-if="isLoadingSpells" class="loading-message">
<div class="loading-spinner"></div>
<p>Lade Zauber...</p>
</div>
<div v-else class="spells-list">
<div v-if="availableSpellsForSelectedCategory.length === 0" class="no-spells-message">
<p>Keine Zauber in dieser Kategorie verfügbar.</p>
</div>
<div v-else>
<div
v-for="spell in availableSpellsForSelectedCategory"
:key="spell.name"
class="spell-item"
:class="{
'can-select': canAffordSpellInCategory(spell),
'cannot-select': !canAffordSpellInCategory(spell)
}"
@click="selectSpellForLearning(spell)"
>
<div class="spell-header">
<span class="spell-name">{{ spell.name }}</span>
<span class="spell-level">Stufe {{ spell.level }}</span>
</div>
<div class="spell-details">
<span class="spell-school">{{ spell.school }}</span>
<span class="spell-cost">{{ getSpellCost(spell) }} LE</span>
</div>
<div v-if="spell.description" class="spell-description">
{{ spell.description }}
</div>
<div v-if="canAffordSpellInCategory(spell)" class="select-icon">
<i class="fas fa-plus"></i>
</div>
<div v-else class="cannot-select-reason">
<span v-if="isSpellSelected(spell)">Bereits gewählt</span>
<span v-else>Nicht genug LE</span>
</div>
</div>
</div>
</div>
</div>
<!-- Center Column: Selected Spells -->
<div class="card">
<div class="section-header">
<h4>Gewählte Zauber</h4>
<!--<span class="count-badge">{{ selectedSpells.length }}</span>-->
</div>
<div v-if="selectedSpells.length === 0" class="no-selection-message">
<p>Noch keine Zauber gewählt.</p>
</div>
<div v-else class="selected-spells-list">
<div
v-for="spell in selectedSpells"
:key="spell.name"
class="selected-spell-item"
>
<div class="spell-header-with-remove">
<div class="spell-header">
<span class="spell-name">{{ spell.name }}</span>
<span class="spell-level">Grad {{ spell.level }}</span>
</div>
<button
@click="removeSpellFromSelection(spell)"
class="remove-button"
:title="'Zauber entfernen'"
>
<i class="fas fa-times"></i>
</button>
</div>
<div class="spell-details">
<span class="spell-school">{{ spell.school }}</span>
<span class="spell-cost">{{ getSpellCost(spell) }} LE</span>
<span class="spell-category">{{ spell.category }}</span>
</div>
</div>
</div>
<!-- Total costs display -->
<div v-if="selectedSpells.length > 0" class="total-costs">
<div class="cost-item">
<span class="cost-label">Gesamt LE:</span>
<span class="cost-value">{{ totalSelectedLE }}</span>
</div>
</div>
</div>
<!-- Right Column: Spell Categories -->
<div class="card">
<div class="section-header">
<h4>Zauberarten</h4>
<!--<span class="info-badge">Verfügbare Kategorien</span>-->
</div>
<div v-if="spellCategories.length === 0" class="no-categories-message">
<p>Keine Zauberarten für diese Charakterklasse verfügbar.</p>
</div>
<div v-else class="categories-list">
<div
v-for="category in spellCategories"
:key="category.name"
class="category-item"
:class="{
'selected': selectedCategory === category.name,
'has-spells': category.spellCount > 0,
'no-spells': category.spellCount === 0
}"
@click="selectCategory(category.name)"
>
<div class="category-header">
<span class="category-name">{{ category.displayName }}</span>
<span class="spell-count">{{ category.spellCount }} Zauber</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Navigation -->
<div class="form-row" style="justify-content: space-between; padding-top: 20px; border-top: 1px solid #dee2e6; margin-top: 30px;">
<button type="button" @click="handlePrevious" class="btn btn-secondary">
Zurück: Fertigkeiten
</button>
<button
type="button"
@click="handleFinalize"
class="btn btn-primary"
>
Charakter erstellen
<i class="fas fa-check"></i>
</button>
</div>
<!-- Debug Info (removable) -->
<div v-if="showDebug || error" class="card" style="margin-top: 20px; font-family: monospace; font-size: 12px;">
<div class="section-header">
<h4>Debug Information</h4>
<button @click="showDebug = !showDebug" class="btn btn-sm">{{ showDebug ? 'Hide' : 'Show' }} Debug</button>
</div>
<div v-if="showDebug || error">
<pre style="margin: 0; white-space: pre-wrap; word-break: break-all;">{{ debugInfo }}</pre>
</div>
</div>
</div>
</template>
<script>
import API from '@/utils/api'
export default {
name: 'CharacterSpells',
props: {
sessionData: {
type: Object,
required: true
}
},
emits: ['previous', 'finalize', 'save'],
data() {
return {
// Loading states
isLoading: false,
isLoadingSpells: false,
error: null,
// Learning points data
learningPointsData: null,
// Spells data
availableSpellsByCategory: null,
spellCategories: [],
// Spells selection
selectedCategory: null,
selectedSpells: [],
// Spell points tracking
spellPoints: {
total: 0,
remaining: 0
},
// Debug
showDebug: false // Set to true for debugging
}
},
computed: {
// Get character class from session data
characterClass() {
return this.sessionData?.typ || ''
},
// Available spells for the selected category
availableSpellsForSelectedCategory() {
if (!this.selectedCategory || !this.availableSpellsByCategory) {
return []
}
// Try to find the matching category key
const categoryKey = this.findCategoryKey(this.selectedCategory)
if (!categoryKey) {
return []
}
// Get spells from the selected category
const categorySpells = this.availableSpellsByCategory[categoryKey] || []
const filteredSpells = categorySpells.map(spell => ({
...spell,
cost: this.getSpellCost(spell),
category: categoryKey // Use the actual category key from availableSpellsByCategory
}))
.filter(spell => {
// Remove already selected spells
const selectedSpellNames = this.selectedSpells.map(s => s.name)
return !selectedSpellNames.includes(spell.name)
})
.sort((a, b) => {
const aLeCost = this.getSpellCost(a)
const bLeCost = this.getSpellCost(b)
// First sort by LE cost (ascending)
if (aLeCost !== bLeCost) {
return aLeCost - bLeCost
}
// If costs are equal, sort by level, then alphabetically
if (a.level !== b.level) {
return a.level - b.level
}
return a.name.localeCompare(b.name)
})
return filteredSpells
},
totalSelectedLE() {
return this.selectedSpells.reduce((total, spell) => total + this.getSpellCost(spell), 0)
},
debugInfo() {
return {
// Session Data
hasSessionData: !!this.sessionData,
sessionDataKeys: this.sessionData ? Object.keys(this.sessionData) : null,
// Character Info
characterClass: this.characterClass,
// Loading States
isLoading: this.isLoading,
isLoadingSpells: this.isLoadingSpells,
error: this.error,
// Data States
hasAvailableSpells: !!this.availableSpellsByCategory,
spellCategoriesCount: this.spellCategories.length,
selectedSpellsCount: this.selectedSpells.length,
selectedCategory: this.selectedCategory,
totalSelectedLE: this.totalSelectedLE,
// Raw Data (for debugging)
availableSpellsByCategory: this.availableSpellsByCategory,
spellCategories: this.spellCategories
}
}
},
watch: {
selectedSpells: {
handler(newSpells) {
// Save spells automatically when they change
this.$emit('save', {
spells: newSpells,
spell_points: {
total: this.spellPoints.total,
remaining: this.spellPoints.remaining
},
spells_meta: {
selectedCategory: this.selectedCategory,
totalLE: this.totalSelectedLE
}
})
},
deep: true
}
},
created() {
// Initialize with existing session data if available
if (this.sessionData.spells && Array.isArray(this.sessionData.spells)) {
this.selectedSpells = [...this.sessionData.spells]
}
// Restore selected category if available
if (this.sessionData.spells_meta?.selectedCategory) {
this.selectedCategory = this.sessionData.spells_meta.selectedCategory
}
// Initialize component
this.initializeComponent()
},
methods: {
async initializeComponent() {
try {
this.isLoading = true
this.error = null
// Load learning points data first
await this.loadLearningPoints()
// Load available spells
await this.loadAvailableSpells()
// Restore previously selected spells if any
this.restoreSelectedSpells()
// Update spell points based on selected spells
this.updateSpellPoints()
} catch (error) {
console.error('Error initializing component:', error)
this.error = 'Fehler beim Laden der Zauber. Bitte versuchen Sie es erneut.'
} finally {
this.isLoading = false
}
},
async loadLearningPoints() {
if (!this.characterClass) {
throw new Error('Charakterklasse nicht verfügbar')
}
try {
const params = {
class: this.characterClass
}
// Add stand if available
if (this.sessionData.stand) {
params.stand = this.sessionData.stand
}
const response = await API.get('/api/characters/classes/learning-points', { params })
this.learningPointsData = response.data
// Initialize spell points from learning points data
const spellPoints = this.learningPointsData.spell_points || 0
this.spellPoints = {
total: spellPoints,
remaining: spellPoints
}
} catch (error) {
console.error('Error loading learning points:', error)
// Set default spell points if API fails
this.spellPoints = {
total: 10, // Default fallback
remaining: 10
}
}
},
async loadAvailableSpells() {
if (!this.characterClass) {
throw new Error('Charakterklasse nicht verfügbar')
}
this.isLoadingSpells = true
try {
const token = localStorage.getItem('token')
const request = {
characterClass: this.characterClass
}
const response = await API.post('/api/characters/available-spells-creation', request, {
headers: { Authorization: `Bearer ${token}` },
})
this.availableSpellsByCategory = response.data.spells_by_category || {}
this.processSpellCategories()
} catch (error) {
console.error('Error loading spells:', error)
this.generateSampleSpells()
} finally {
this.isLoadingSpells = false
}
},
processSpellCategories() {
// Convert spell categories from the backend response
this.spellCategories = Object.entries(this.availableSpellsByCategory || {}).map(([categoryKey, spells]) => ({
name: categoryKey.toLowerCase(),
displayName: categoryKey,
spellCount: spells.length
}))
// Sort categories by name
this.spellCategories.sort((a, b) => a.displayName.localeCompare(b.displayName))
},
generateSampleSpells() {
// Fallback for testing
this.availableSpellsByCategory = {
'Dweomer': [
{ name: 'Licht', level: 1, school: 'Dweomer', le_cost: 1, description: 'Erzeugt ein helles Licht' },
{ name: 'Zauberschutz', level: 1, school: 'Dweomer', le_cost: 1, description: 'Schutz vor Magie' }
],
'Erkennen': [
{ name: 'Zaubersicht', level: 1, school: 'Erkennen', le_cost: 1, description: 'Erkennt magische Auren' },
{ name: 'Gedankenlesen', level: 2, school: 'Erkennen', le_cost: 2, description: 'Liest Oberflächengedanken' }
],
'Verändern': [
{ name: 'Heilen von Wunden', level: 1, school: 'Verändern', le_cost: 1, description: 'Heilt leichte Verletzungen' },
{ name: 'Stärke', level: 2, school: 'Verändern', le_cost: 2, description: 'Erhöht die körperliche Stärke' }
]
}
this.processSpellCategories()
console.log('Generated sample spells:', this.availableSpellsByCategory)
},
restoreSelectedSpells() {
if (this.sessionData.spells && Array.isArray(this.sessionData.spells)) {
this.selectedSpells = [...this.sessionData.spells]
}
},
async selectCategory(categoryName) {
this.selectedCategory = categoryName
// Spells are now provided by computed property availableSpellsForSelectedCategory
},
getSelectedCategoryName() {
const category = this.spellCategories.find(cat => cat.name === this.selectedCategory)
return category ? category.displayName : this.selectedCategory
},
findCategoryKey(selectedCategoryName) {
if (!this.availableSpellsByCategory) return null
// Try to find by spell category mapping first (most likely scenario)
const spellCategory = this.spellCategories?.find(cat => cat.name === selectedCategoryName)
if (spellCategory && this.availableSpellsByCategory[spellCategory.displayName]) {
return spellCategory.displayName
}
// Try direct match
if (this.availableSpellsByCategory[selectedCategoryName]) {
return selectedCategoryName
}
// Try case-insensitive search
const availableKeys = Object.keys(this.availableSpellsByCategory)
const foundKey = availableKeys.find(key =>
key.toLowerCase() === selectedCategoryName.toLowerCase()
)
return foundKey || null
},
getSpellCost(spell) {
// Unified method to get spell cost from various possible properties
return spell.cost || spell.le_cost || spell.leCost || 0
},
canAffordSpellInCategory(spell) {
// Check if already selected
if (this.isSpellSelected(spell)) {
return false
}
// Check if enough spell points remaining
const spellCost = this.getSpellCost(spell)
return this.spellPoints.remaining >= spellCost
},
isSpellSelected(spell) {
return this.selectedSpells.some(s => s.name === spell.name)
},
updateSpellPoints() {
// Reset to total points
this.spellPoints.remaining = this.spellPoints.total
// Deduct points for selected spells
this.selectedSpells.forEach(spell => {
this.spellPoints.remaining -= this.getSpellCost(spell)
})
// Ensure remaining points don't go below 0
this.spellPoints.remaining = Math.max(0, this.spellPoints.remaining)
},
restoreSelectedSpells() {
if (this.sessionData.spells && Array.isArray(this.sessionData.spells)) {
this.selectedSpells = [...this.sessionData.spells]
}
// Also restore spell points if saved
if (this.sessionData.spell_points) {
this.spellPoints.remaining = this.sessionData.spell_points.remaining || this.spellPoints.total
}
},
selectSpellForLearning(spell) {
if (this.isSpellSelected(spell)) {
return
}
// Check if the spell can be afforded
if (!this.canAffordSpellInCategory(spell)) {
return
}
// Add spell to selected list with proper cost and category
const spellToAdd = {
...spell,
cost: this.getSpellCost(spell)
}
this.selectedSpells.push(spellToAdd)
// Update spell points
this.updateSpellPoints()
console.log('Spell selected for learning:', spell.name, 'Cost:', spellToAdd.cost)
},
removeSpellFromSelection(spell) {
const index = this.selectedSpells.findIndex(s => s.name === spell.name)
if (index !== -1) {
this.selectedSpells.splice(index, 1)
// Update spell points
this.updateSpellPoints()
console.log('Spell removed from selection:', spell.name)
}
},
async retry() {
await this.initializeComponent()
},
handlePrevious() {
this.$emit('previous')
},
handleFinalize() {
const data = {
spells: this.selectedSpells,
spell_points: {
total: this.spellPoints.total,
remaining: this.spellPoints.remaining
},
spells_meta: {
selectedCategory: this.selectedCategory,
totalLE: this.totalSelectedLE
}
}
console.log('Finalizing with data:', data)
this.$emit('finalize', data)
}
}
}
</script>
<style>
/* All common styles moved to main.css */
.spell-points-display {
margin: 0 20px 30px 20px;
padding: 16px;
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border: 1px solid #dee2e6;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.points-header h4 {
margin: 0 0 12px 0;
color: #495057;
font-size: 1.1rem;
font-weight: 600;
}
.points-info {
display: flex;
align-items: baseline;
gap: 6px;
margin-bottom: 8px;
}
.points-remaining {
font-size: 1.8rem;
font-weight: 700;
color: #007bff;
}
.points-separator {
font-size: 1.4rem;
color: #6c757d;
margin: 0 2px;
}
.points-total {
font-size: 1.4rem;
font-weight: 600;
color: #6c757d;
}
.points-label {
font-size: 0.9rem;
color: #6c757d;
margin-left: 4px;
}
.points-usage {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
}
.usage-label {
color: #6c757d;
}
.usage-value {
color: #dc3545;
font-weight: 600;
}
.fullwidth-page {
/*
padding: 0 !important;
margin: 0 !important;
width: 100vw !important;
max-width: 100vw !important;
box-sizing: border-box !important;
*/
}
.page-header {
padding: 15px 20px;
margin-bottom: 20px;
}
.three-column-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 30px;
margin: 0 20px 30px 20px;
width: calc(100vw - 40px);
max-width: calc(100vw - 40px);
box-sizing: border-box;
}
.spells-content {
width: 100%;
max-width: 100%;
}
.spell-item {
padding: 12px;
border: 1px solid #ddd;
border-radius: 6px;
margin-bottom: 8px;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.spell-item.can-select {
border-color: #28a745;
background-color: #f8fff9;
}
.spell-item.can-select:hover {
border-color: #20c997;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.spell-item.cannot-select {
border-color: #dc3545;
background-color: #fff5f5;
cursor: not-allowed;
opacity: 0.7;
}
.spell-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.spell-name {
font-weight: 600;
color: #333;
}
.spell-level {
font-size: 0.85em;
color: #666;
background: #f8f9fa;
padding: 2px 6px;
border-radius: 4px;
}
.spell-details {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.9em;
color: #666;
margin-bottom: 6px;
}
.spell-school {
color: #007bff;
font-weight: 500;
}
.spell-cost {
color: #28a745;
font-weight: 600;
}
.spell-description {
font-size: 0.85em;
color: #666;
font-style: italic;
margin-top: 6px;
line-height: 1.3;
}
.select-icon {
position: absolute;
top: 8px;
right: 8px;
color: #28a745;
font-size: 1.1em;
}
.cannot-select-reason {
position: absolute;
top: 8px;
right: 8px;
font-size: 0.8em;
color: #dc3545;
text-align: right;
}
.selected-spell-item {
padding: 12px;
border: 1px solid #007bff;
border-radius: 6px;
margin-bottom: 8px;
background: #f8fcff;
}
.spell-header-with-remove {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 6px;
}
.selected-spell-item .spell-category {
color: #6c757d;
font-size: 0.8em;
background: #e9ecef;
padding: 1px 4px;
border-radius: 3px;
}
.remove-button {
background: #dc3545;
border: none;
color: white;
cursor: pointer;
padding: 6px;
border-radius: 4px;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s ease;
flex-shrink: 0;
}
.remove-button:hover {
background-color: #c82333;
}
.category-item {
padding: 12px;
border: 1px solid #ddd;
border-radius: 6px;
margin-bottom: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.category-item:hover {
border-color: #007bff;
background-color: #f8fcff;
}
.category-item.selected {
border-color: #007bff;
background-color: #e3f2fd;
}
.category-item.no-spells {
opacity: 0.6;
cursor: not-allowed;
}
.category-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.category-name {
font-weight: 600;
color: #333;
}
.spell-count {
font-size: 0.85em;
color: #666;
background: #f8f9fa;
padding: 2px 6px;
border-radius: 4px;
}
.total-costs {
border-top: 1px solid #dee2e6;
padding-top: 12px;
margin-top: 12px;
}
.cost-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.cost-label {
font-weight: 500;
color: #495057;
}
.cost-value {
font-weight: 600;
color: #007bff;
}
.opacity-50 {
opacity: 0.6;
}
.no-selection-message,
.no-spells-message,
.no-categories-message {
text-align: center;
color: #6c757d;
font-style: italic;
padding: 20px;
}
.category-badge,
.count-badge,
.info-badge {
font-size: 0.8em;
padding: 2px 6px;
border-radius: 4px;
background: #e9ecef;
color: #495057;
}
.count-badge {
background: #007bff;
color: white;
}
.info-badge {
background: #17a2b8;
color: white;
}
@media (max-width: 1200px) {
.three-column-grid {
grid-template-columns: 1fr 1fr;
gap: 20px;
}
}
@media (max-width: 768px) {
.three-column-grid {
grid-template-columns: 1fr;
gap: 15px;
}
}
</style>
@@ -1,109 +0,0 @@
<template>
<div v-if="sessions.length > 0" class="sessions-section">
<div class="section-header">
<h3>{{ $t('characters.list.continue_creation') }}</h3>
</div>
<div class="grid-container grid-2-columns">
<div
v-for="session in sessions"
:key="session.session_id"
class="card session-card"
@click="continueSession(session.session_id)"
>
<div class="session-header">
<h4 class="list-item-title">{{ session.name || $t('characters.list.unnamed_character') }}</h4>
<span class="badge badge-primary progress-badge">{{ $t('characters.list.step') }} {{ session.current_step }}/{{ session.total_steps }}</span>
</div>
<div class="session-details">
<p class="list-item-details"><strong>{{ $t('characters.list.race') }}:</strong> {{ session.rasse || $t('characters.list.not_selected') }}
<span class="list-item-separator">|</span><strong>{{ $t('characters.list.class') }}:</strong> {{ session.typ || $t('characters.list.not_selected') }} </p>
<p class="list-item-details"><strong>{{ $t('characters.list.current_step') }}:</strong> {{ session.progress_text }} </p>
</div>
<div class="session-meta">
<span class="session-date">{{ $t('characters.list.last_updated') }}: {{ formatDate(session.updated_at) }}</span>
<span class="session-date">{{ $t('characters.list.expires') }}: {{ formatDate(session.expires_at) }}</span>
</div>
<div class="list-item-actions">
<button @click.stop="deleteSession(session.session_id)" class="btn btn-danger btn-small">
{{ $t('characters.list.delete_draft') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import { formatDate } from '@/utils/dateUtils'
export default {
name: 'CharacterCreationSessions',
props: {
sessions: {
type: Array,
default: () => []
}
},
methods: {
continueSession(sessionId) {
this.$emit('continue-session', sessionId)
},
deleteSession(sessionId) {
this.$emit('delete-session', sessionId)
},
formatDate
}
}
</script>
<style>
/* All common styles moved to main.css */
.sessions-section {
margin-bottom: 30px;
}
.session-card {
cursor: pointer;
}
.session-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.session-details {
margin-bottom: 10px;
}
.session-meta {
display: flex;
flex-direction: column;
gap: 5px;
margin-bottom: 10px;
padding-top: 10px;
border-top: 1px solid #eee;
}
.session-date {
font-size: 0.8rem;
color: #888;
}
.btn-small {
padding: 5px 10px;
font-size: 0.8rem;
}
@media (max-width: 768px) {
.session-header {
flex-direction: column;
align-items: flex-start;
gap: 5px;
}
}
</style>
@@ -1,161 +0,0 @@
<template>
<div class="character-details">
<!-- Character Header -->
<div class="character-header">
<div class="header-content">
<button @click="showExportDialog = true" class="export-button-small" :title="$t('export.title')">
📄
</button>
<button v-if="isOwner" @click="showVisibilityDialog = true" class="export-button-small" :title="$t('visibility.title')">
{{ character.public ? '🌐' : '🔒' }}
</button>
<h2>{{ character.name }} {{$t('characters.list.class')}}: {{ character.typ }} {{$t('characters.list.grade') }}: {{ character.grad }} <!--({{ $t(currentView) }})--></h2>
</div>
</div>
<!-- Export Dialog -->
<ExportDialog
:characterId="id"
:showDialog="showExportDialog"
@update:showDialog="showExportDialog = $event"
@export-success="handleExportSuccess"
/>
<!-- Visibility Dialog -->
<VisibilityDialog
:characterId="id"
:currentVisibility="character.public"
:showDialog="showVisibilityDialog"
@update:showDialog="showVisibilityDialog = $event"
@visibility-updated="handleVisibilityUpdated"
/>
<!-- Submenu Content -->
<!-- <div class="character-aspect"> -->
<component :is="currentView" :character="character" :isOwner="isOwner" @character-updated="refreshCharacter"/>
<!-- </div> -->
<!-- Submenu -->
<div class="submenu">
<button
v-for="menu in menus"
:key="menu.id"
:class="{ active: currentView === menu.component }"
@click="changeView(menu.component)"
>
{{ $t( 'menu.'+ menu.name ) }}
</button>
</div>
</div>
</template>
<style>
/* Component-specific styles only - global styles in main.css */
.character-details {
width: 100%;
height: 100%;
padding: 20px;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
</style>
<script>
import API from '../utils/api'
import { useUserStore } from '../stores/userStore'
import ExportDialog from "./ExportDialog.vue";
import VisibilityDialog from "./VisibilityDialog.vue";
import DatasheetView from "./DatasheetView.vue"; // Component for character stats
import SkillView from "./SkillView.vue"; // Component for character history
import WeaponView from "./WeaponView.vue"; // Component for character history
import SpellView from "./SpellView.vue"; // Component for character history
import EquipmentView from "./EquipmentView.vue"; // Component for character equipment
import ExperianceView from "./ExperianceView.vue"; // Component for character history
import DeleteCharView from "./DeleteCharView.vue"; // Component for character history
export default {
name: "CharacterDetails",
props: ["id"], // Receive the route parameter as a prop
components: {
ExportDialog,
VisibilityDialog,
DatasheetView,
SkillView,
WeaponView,
SpellView,
EquipmentView,
ExperianceView,
DeleteCharView,
},
data() {
return {
character: {},
currentView: "DatasheetView", // Default view
lastView: "DatasheetView",
showExportDialog: false,
showVisibilityDialog: false,
menus: [
{ id: 1, name: "Datasheet", component: "DatasheetView" },
{ id: 2, name: "Skill", component: "SkillView" },
{ id: 3, name: "Weapon", component: "WeaponView" },
{ id: 4, name: "Spell", component: "SpellView" },
{ id: 5, name: "Equipment", component: "EquipmentView" },
{ id: 6, name: "Experiance", component: "ExperianceView" },
{ id: 6, name: "DeleteChar", component: "DeleteCharView" },
//{ id: 3, name: "History", component: "HistoryView" },
//{ id: 2, name: "Notes", component: "NotesView" },
//{ id: 2, name: "Campagne", component: "CampagneView" },
],
};
},
computed: {
isOwner() {
const userStore = useUserStore()
return userStore.currentUser && this.character.user_id === userStore.currentUser.id
}
},
async created() {
const token = localStorage.getItem('token')
const response = await API.get(`/api/characters/${this.id}`, {
headers: { Authorization: `Bearer ${token}` },
})
this.character = response.data
},
methods: {
handleExportSuccess() {
console.log('PDF exported successfully')
},
handleVisibilityUpdated(isPublic) {
this.character.public = isPublic
this.showVisibilityDialog = false
console.log('Character visibility updated to:', isPublic ? 'public' : 'private')
},
changeView(view) {
this.lastView = this.currentView;
this.currentView = view;
},
async refreshCharacter() {
// Lade die Charakterdaten neu nach einer Aktualisierung
try {
const token = localStorage.getItem('token');
const response = await API.get(`/api/characters/${this.id}`, {
headers: { Authorization: `Bearer ${token}` },
});
this.character = response.data;
console.log('Character data refreshed after skill update');
} catch (error) {
console.error('Failed to refresh character data:', error);
// Optional: Zeige eine Fehlermeldung an
alert('Fehler beim Aktualisieren der Charakterdaten: ' + (error.response?.data?.error || error.message));
}
},
},
};
</script>
-230
View File
@@ -1,230 +0,0 @@
<template>
<div class="fullwidth-container">
<div class="page-header">
<h2>{{ $t('characters.list.title') }}</h2>
</div>
<!-- Create New Character Button -->
<div class="create-character-section">
<button @click="createNewCharacter" class="btn btn-success btn-large">{{ $t('characters.list.create_new') }}</button>
</div>
<!-- Active Character Creation Sessions -->
<CharacterCreationSessions
:sessions="creationSessions"
@continue-session="continueSession"
@delete-session="handleDeleteSession"
/>
<div v-if="ownedCharacters.length === 0" class="empty-state">
<h3>{{ $t('characters.list.no_characters') }}</h3>
<p>{{ $t('characters.list.no_characters_description') }}</p>
</div>
<div v-else class="list-container horizontal-placement">
<div class="charlist">
<div class="charlist-header">{{ $t('characters.list.owned_characters_title') }}</div>
<div v-for="character in ownedCharacters" :key="character.character_id" class="list-item">
<router-link :to="`/character/${character.id}`" class="list-item-content">
<h4 class="list-item-title">{{ character.name }}</h4>
<div class="list-item-details">
{{ character.rasse }} <span class="list-item-separator">|</span>
{{ character.typ }} <span class="list-item-separator">|</span>
{{ $t('characters.list.grade') }}: {{ character.grad }} <span class="list-item-separator">|</span>
{{ $t('characters.list.owner') }}: {{ character.owner }} <span class="list-item-separator">|</span>
<span class="badge" :class="character.public ? 'badge-success' : 'badge-secondary'">
{{ character.public ? $t('characters.list.public') : $t('characters.list.private') }}
</span>
</div>
</router-link>
</div>
</div>
<div class="charlist">
<div class="charlist-header">{{ $t('characters.list.shared_characters_title') }}</div>
<div v-for="character in sharedCharacters" :key="character.character_id" class="list-item">
<router-link :to="`/character/${character.id}`" class="list-item-content">
<h4 class="list-item-title">{{ character.name }}</h4>
<div class="list-item-details">
{{ character.rasse }} <span class="list-item-separator">|</span>
{{ character.typ }} <span class="list-item-separator">|</span>
{{ $t('characters.list.grade') }}: {{ character.grad }} <span class="list-item-separator">|</span>
{{ $t('characters.list.owner') }}: {{ character.owner }} <span class="list-item-separator">|</span>
<span class="badge" :class="character.public ? 'badge-success' : 'badge-secondary'">
{{ character.public ? $t('characters.list.public') : $t('characters.list.private') }}
</span>
</div>
</router-link>
</div>
</div>
</div>
</div>
</template>
<script>
import API from '../utils/api'
import { formatDate } from '@/utils/dateUtils'
import CharacterCreationSessions from './CharacterCreationSessions.vue'
export default {
components: {
CharacterCreationSessions
},
data() {
return {
ownedCharacters: [],
sharedCharacters: [],
creationSessions: [],
}
},
async created() {
await this.loadCharacters()
await this.loadCreationSessions()
},
methods: {
async loadCharacters() {
try {
const token = localStorage.getItem('token')
const response = await API.get('/api/characters', {
headers: { Authorization: `Bearer ${token}` },
})
this.ownedCharacters = response.data.self_owned
this.sharedCharacters = response.data.others
} catch (error) {
console.error('Error loading characters:', error)
}
},
async loadCreationSessions() {
try {
const token = localStorage.getItem('token')
const response = await API.get('/api/characters/create-sessions', {
headers: { Authorization: `Bearer ${token}` },
})
this.creationSessions = response.data.sessions || []
} catch (error) {
console.error('Error loading creation sessions:', error)
// Don't show error to user since this is a new feature
this.creationSessions = []
}
},
continueSession(sessionId) {
this.$router.push(`/character/create/${sessionId}`)
},
handleDeleteSession(sessionId) {
this.deleteSession(sessionId)
},
async deleteSession(sessionId) {
if (confirm(this.$t('characters.list.delete_draft_confirm'))) {
try {
const token = localStorage.getItem('token')
await API.delete(`/api/characters/create-session/${sessionId}`, {
headers: { Authorization: `Bearer ${token}` },
})
// Reload sessions after deletion
await this.loadCreationSessions()
} catch (error) {
console.error('Error deleting session:', error)
alert('Error deleting character draft')
}
}
},
goToAusruestung(characterId) {
this.$router.push(`/api/ausruestung/${characterId}`)
},
async createNewCharacter() {
try {
const token = localStorage.getItem('token')
const response = await API.post('/api/characters/create-session', {}, {
headers: { Authorization: `Bearer ${token}` },
})
const sessionId = response.data.session_id
this.$router.push(`/character/create/${sessionId}`)
} catch (error) {
console.error('Error creating character session:', error)
alert('Fehler beim Erstellen der Charakter-Session')
}
},
formatDate
},
}
</script>
<style scoped>
/* All common styles moved to main.css */
.create-character-section {
margin-bottom: 30px;
padding: 20px;
border: 2px dashed #28a745;
border-radius: 8px;
text-align: center;
background-color: #f8fff9;
}
.btn-large {
padding: 12px 24px;
font-size: 16px;
}
/* RouterLink als list-item-content styling */
.list-item-content {
flex: 1;
text-decoration: none;
color: inherit;
display: block;
}
.list-item-content:hover {
text-decoration: none;
color: inherit;
}
.list-item-content:hover .list-item-title {
color: #007bff;
}
.horizontal-placement {
display: flex;
gap: 10px;
align-items: flex-start;
}
.charlist {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
max-height: calc(100vh - 15px - 300px);
overflow-y: auto;
}
.charlist-header {
font-size: 1.25rem;
font-weight: 600;
color: #333;
padding: 12px 20px;
background: #f8f9fa;
border-bottom: 2px solid #dee2e6;
margin-bottom: 0;
flex-shrink: 0;
}
/* Responsive Design */
@media (max-width: 768px) {
.list-item {
flex-direction: column;
gap: 10px;
}
.list-item-actions {
align-self: stretch;
justify-content: flex-start;
}
}
</style>
-441
View File
@@ -1,441 +0,0 @@
<template>
<div class="fullwidth-container datasheet-container" v-if="character">
<!-- Character Overview -->
<div class="character-overview">
<div class="character-image">
<img :src="imageSrc" alt="Character Image"/>
<ImageUploadCropper
:characterId="character.id"
@image-updated="handleImageUpdate"
/>
</div>
<div class="character-stats">
<div class="stat" v-for="(stat, index) in characterStats" :key="index">
<span>{{ $t(stat.label) }}</span>
<strong
v-if="editingIndex !== index"
@dblclick="startEdit(index, stat.path)"
class="editable-value"
>
{{ getStat(stat.path) }}
</strong>
<input
v-else
v-model="editValue"
@blur="saveEdit(stat.path)"
@keyup.enter="saveEdit(stat.path)"
@keyup.esc="cancelEdit"
ref="editInput"
type="number"
class="edit-input"
/>
</div>
</div>
</div>
<!-- Character Information -->
<div class="character-info">
<div class="info-section">
<label for="name"><span
class="help-icon"
:title="$t('characters.datasheet.editHelp')"
role="img"
:aria-label="$t('characters.datasheet.editHelp')"
>
?
</span></label>
<p>
<strong>{{ $t('char') }}:</strong>
<span
v-if="editingProp !== 'name'"
@dblclick="startEditProp('name', character.name)"
class="editable-prop"
>{{ character.name || '-' }}</span>
<input
v-else
v-model="editPropValue"
@blur="saveProp('name')"
@keyup.enter="saveProp('name')"
@keyup.esc="cancelEditProp"
ref="propInput"
class="prop-input"
/>
</p>
<p>
<span
v-if="editingProp !== 'typ'"
@dblclick="startEditProp('typ', character.typ)"
class="editable-prop"
>{{ character.typ || 'x' }}</span>
<input v-else v-model="editPropValue" @blur="saveProp('typ')" @keyup.enter="saveProp('typ')" @keyup.esc="cancelEditProp" ref="propInput" class="prop-input" />
(
<span
v-if="editingProp !== 'gender'"
@dblclick="startEditProp('gender', character.gender)"
class="editable-prop"
>{{ character.gender || 'x' }}</span>
<select v-else v-model="editPropValue" @blur="saveProp('gender')" @keyup.enter="saveProp('gender')" @keyup.esc="cancelEditProp" ref="propInput" class="prop-input">
<option v-for="option in getSelectOptions('gender')" :key="option" :value="option">{{ option }}</option>
</select>
),
Grad:
<span
v-if="editingProp !== 'grad'"
@dblclick="startEditProp('grad', character.grad, 'number')"
class="editable-prop"
>{{ character.grad || 'x' }}</span>
<input v-else v-model="editPropValue" type="number" @blur="saveProp('grad')" @keyup.enter="saveProp('grad')" @keyup.esc="cancelEditProp" ref="propInput" class="prop-input" />,
Rasse:
<span
v-if="editingProp !== 'rasse'"
@dblclick="startEditProp('rasse', character.rasse)"
class="editable-prop"
>{{ character.rasse || 'x' }}</span>
<select v-else v-model="editPropValue" @blur="saveProp('rasse')" @keyup.enter="saveProp('rasse')" @keyup.esc="cancelEditProp" ref="propInput" class="prop-input">
<option v-for="option in getSelectOptions('rasse')" :key="option" :value="option">{{ option }}</option>
</select>,
Heimat:
<span
v-if="editingProp !== 'origin'"
@dblclick="startEditProp('origin', character.origin)"
class="editable-prop"
>{{ character.origin || '-' }}</span>
<select v-else v-model="editPropValue" @blur="saveProp('origin')" @keyup.enter="saveProp('origin')" @keyup.esc="cancelEditProp" ref="propInput" class="prop-input">
<option v-for="option in getSelectOptions('origin')" :key="option" :value="option">{{ option }}</option>
</select>,
Stand:
<span
v-if="editingProp !== 'social_class'"
@dblclick="startEditProp('social_class', character.social_class)"
class="editable-prop"
>{{ character.social_class || '-' }}</span>
<select v-else v-model="editPropValue" @blur="saveProp('social_class')" @keyup.enter="saveProp('social_class')" @keyup.esc="cancelEditProp" ref="propInput" class="prop-input">
<option v-for="option in getSelectOptions('social_class')" :key="option" :value="option">{{ option }}</option>
</select>.
</p>
<p v-if="character.rasse==='Zwerg'">
Hort für Grad {{ character.grad || 'x' }}: 125 GS, für nächsten Grad: 250 GS.
</p>
<p>
<strong>Spezialisierung:</strong>
<span
v-if="editingProp !== 'spezialisierung'"
@dblclick="startEditProp('spezialisierung', character.spezialisierung)"
class="editable-prop"
>{{ character.spezialisierung || '-' }}</span>
<select v-else v-model="editPropValue" @blur="saveProp('spezialisierung')" @keyup.enter="saveProp('spezialisierung')" @keyup.esc="cancelEditProp" ref="propInput" class="prop-input" style="width: 300px;">
<option value="">-</option>
<option v-for="option in getSelectOptions('spezialisierung')" :key="option" :value="option">{{ option }}</option>
</select>.
</p>
<p>
Alter:
<span
v-if="editingProp !== 'alter'"
@dblclick="startEditProp('alter', character.alter, 'number')"
class="editable-prop"
>{{ character.alter || 'xx' }}</span>
<input v-else v-model="editPropValue" type="number" @blur="saveProp('alter')" @keyup.enter="saveProp('alter')" @keyup.esc="cancelEditProp" ref="propInput" class="prop-input" />,
<strong v-if="editingProp !== 'hand'" @dblclick="startEditProp('hand', character.hand)" class="editable-prop">
<span v-if="character.hand=='rechts'">Rechtshänder</span>
<span v-else-if="character.hand=='links'">Linkshänder</span>
<span v-else>Beidhändig</span>
</strong>
<select v-else v-model="editPropValue" @blur="saveProp('hand')" @keyup.enter="saveProp('hand')" @keyup.esc="cancelEditProp" ref="propInput" class="prop-input">
<option v-for="option in getSelectOptions('hand')" :key="option" :value="option">{{ option }}</option>
</select>,
Größe:
<span
v-if="editingProp !== 'groesse'"
@dblclick="startEditProp('groesse', character.groesse, 'number')"
class="editable-prop"
>{{ character.groesse }}</span>
<input v-else v-model="editPropValue" type="number" @blur="saveProp('groesse')" @keyup.enter="saveProp('groesse')" @keyup.esc="cancelEditProp" ref="propInput" class="prop-input" />cm,
Gewicht:
<span
v-if="editingProp !== 'gewicht'"
@dblclick="startEditProp('gewicht', character.gewicht, 'number')"
class="editable-prop"
>{{ character.gewicht }}</span>
<input v-else v-model="editPropValue" type="number" @blur="saveProp('gewicht')" @keyup.enter="saveProp('gewicht')" @keyup.esc="cancelEditProp" ref="propInput" class="prop-input" />kg,
Gestalt:
<span
v-if="editingProp !== 'merkmale.groesse'"
@dblclick="startEditProp('merkmale.groesse', character.merkmale?.groesse)"
class="editable-prop"
>{{ character.merkmale?.groesse || '-' }}</span>
<input v-else v-model="editPropValue" @blur="saveProp('merkmale.groesse')" @keyup.enter="saveProp('merkmale.groesse')" @keyup.esc="cancelEditProp" ref="propInput" class="prop-input" />
und
<span
v-if="editingProp !== 'merkmale.breite'"
@dblclick="startEditProp('merkmale.breite', character.merkmale?.breite)"
class="editable-prop"
>{{ character.merkmale?.breite || '-' }}</span>
<input v-else v-model="editPropValue" @blur="saveProp('merkmale.breite')" @keyup.enter="saveProp('merkmale.breite')" @keyup.esc="cancelEditProp" ref="propInput" class="prop-input" />,
Augen:
<span
v-if="editingProp !== 'merkmale.augenfarbe'"
@dblclick="startEditProp('merkmale.augenfarbe', character.merkmale?.augenfarbe)"
class="editable-prop"
>{{ character.merkmale?.augenfarbe || '-' }}</span>
<input v-else v-model="editPropValue" @blur="saveProp('merkmale.augenfarbe')" @keyup.enter="saveProp('merkmale.augenfarbe')" @keyup.esc="cancelEditProp" ref="propInput" class="prop-input" />,
Haare:
<span
v-if="editingProp !== 'merkmale.haarfarbe'"
@dblclick="startEditProp('merkmale.haarfarbe', character.merkmale?.haarfarbe)"
class="editable-prop"
>{{ character.merkmale?.haarfarbe || '-' }}</span>
<input v-else v-model="editPropValue" @blur="saveProp('merkmale.haarfarbe')" @keyup.enter="saveProp('merkmale.haarfarbe')" @keyup.esc="cancelEditProp" ref="propInput" class="prop-input" />,
Glaube:
<span
v-if="editingProp !== 'glaube'"
@dblclick="startEditProp('glaube', character.glaube)"
class="editable-prop"
>{{ character.glaube || '-' }}</span>
<select
v-else
v-model="editPropValue"
multiple
@blur="saveProp('glaube')"
@keyup.enter="saveProp('glaube')"
@keyup.esc="cancelEditProp"
ref="propInput"
class="prop-input multi-select"
size="8"
>
<option v-for="option in getSelectOptions('glaube')" :key="option" :value="option">{{ option }}</option>
</select>
</p>
<p>
<strong>Merkmale:</strong>
<span
v-if="editingProp !== 'merkmale.sonstige'"
@dblclick="startEditProp('merkmale.sonstige', character.merkmale?.sonstige)"
class="editable-prop"
>{{ character.merkmale?.sonstige || '-' }}</span>
<input v-else v-model="editPropValue" @blur="saveProp('merkmale.sonstige')" @keyup.enter="saveProp('merkmale.sonstige')" @keyup.esc="cancelEditProp" ref="propInput" class="prop-input" style="width: 300px;" />
</p>
<p>
<em>Persönlicher Bonus für</em> Ausdauer 12, Schaden 5, Angriff 2,
Abwehr 0, Zauber 0, Resistenz 3 / 4.
</p>
</div>
</div>
</div>
<div v-else>Loading character data...</div>
</template>
<style scoped>
.multi-select {
min-width: 200px;
max-height: 180px;
overflow-y: auto;
}
</style>
<script>
import ImageUploadCropper from './ImageUploadCropper.vue'
import API from '../utils/api'
export default {
name: "DatasheetView",
components: {
ImageUploadCropper
},
props: {
character: {
type: Object,
required: true
}
},
computed: {
imageSrc() {
return this.character.image
? `${this.character.image}`
: "/token_default.png";
},
},
data() {
return {
editingIndex: null,
editValue: '',
editingProp: null,
editPropValue: '',
editPropType: 'text',
datasheetOptions: null,
characterStats: [
{ label: 'stats.strength', path: 'eigenschaften.6.value' },
{ label: 'stats.dexterity', path: 'eigenschaften.1.value' },
{ label: 'stats.agility', path: 'eigenschaften.2.value' },
{ label: 'stats.constitution', path: 'eigenschaften.4.value' },
{ label: 'stats.intelligence', path: 'eigenschaften.3.value' },
{ label: 'stats.spelltalent', path: 'eigenschaften.8.value' },
{ label: 'stats.beauty', path: 'eigenschaften.0.value' },
{ label: 'stats.charisma', path: 'eigenschaften.5.value' },
{ label: 'stats.willpower', path: 'eigenschaften.7.value' },
{ label: 'stats.poisontolerance', path: 'git' },
{ label: 'stats.movement', path: 'b.max' },
{ label: 'stats.lifepoints', path: 'lp.max'},
{ label: 'stats.staminapoints', path: 'ap.max'},
{ label: 'stats.divinegrace', path: 'bennies.gg'},
{ label: 'stats.fatesfavor', path: 'bennies.sg' }
]
}
},
methods: {
async loadDatasheetOptions() {
if (this.datasheetOptions) return
try {
const response = await API.get(`/api/characters/${this.character.id}/datasheet-options`)
this.datasheetOptions = response.data
} catch (error) {
console.error('Failed to load datasheet options:', error)
alert('Fehler beim Laden der Auswahloptionen')
}
},
handleImageUpdate(newImage) {
this.$emit('character-updated')
},
getStat(path) {
/*
if (path === 'git') {
// Todo: calculate poison tolerance based on character data
return '64!'
}
*/
return path.split('.').reduce((obj, key) => obj?.[key], this.character) ?? '-'
},
startEdit(index, path) {
//if (path === 'git') return
this.editingIndex = index
this.editValue = this.getStat(path)
this.$nextTick(() => {
if (this.$refs.editInput && this.$refs.editInput[0]) {
this.$refs.editInput[0].focus()
this.$refs.editInput[0].select()
}
})
},
async saveEdit(path) {
if (this.editingIndex === null) return
const newValue = parseInt(this.editValue)
if (isNaN(newValue)) {
this.cancelEdit()
return
}
try {
// Update the character object directly
const pathParts = path.split('.')
let obj = this.character
for (let i = 0; i < pathParts.length - 1; i++) {
obj = obj[pathParts[i]]
}
obj[pathParts[pathParts.length - 1]] = newValue
// Save to backend
await API.put(`/api/characters/${this.character.id}`, this.character)
this.$emit('character-updated')
this.cancelEdit()
} catch (error) {
console.error('Failed to update stat:', error)
alert('Fehler beim Speichern: ' + (error.response?.data?.error || error.message))
this.cancelEdit()
}
},
cancelEdit() {
this.editingIndex = null
this.editValue = ''
},
startEditProp(prop, value, type = 'text') {
// Load options if this is a select field
const selectFields = ['gender', 'rasse', 'origin', 'social_class', 'glaube', 'hand', 'spezialisierung']
if (selectFields.includes(prop)) {
this.loadDatasheetOptions()
type = prop === 'glaube' ? 'multi-select' : 'select'
}
this.editingProp = prop
if (prop === 'glaube') {
this.editPropValue = value ? value.split(',').map(v => v.trim()).filter(Boolean) : []
} else {
this.editPropValue = value || ''
}
this.editPropType = type
this.$nextTick(() => {
if (this.$refs.propInput) {
const input = Array.isArray(this.$refs.propInput) ? this.$refs.propInput[0] : this.$refs.propInput
if (input) {
input.focus()
if (type !== 'select' && type !== 'multi-select') {
input.select()
}
}
}
})
},
async saveProp(prop) {
if (this.editingProp === null) return
let newValue = this.editPropValue
if (this.editPropType === 'number') {
newValue = parseInt(this.editPropValue)
if (isNaN(newValue)) {
this.cancelEditProp()
return
}
} else if (this.editPropType === 'multi-select') {
newValue = Array.isArray(this.editPropValue) ? this.editPropValue.join(', ') : ''
}
// Update the character object directly
const pathParts = prop.split('.')
let obj = this.character
for (let i = 0; i < pathParts.length - 1; i++) {
if (!obj[pathParts[i]]) {
obj[pathParts[i]] = {}
}
obj = obj[pathParts[i]]
}
obj[pathParts[pathParts.length - 1]] = newValue
// Save to backend
await API.put(`/api/characters/${this.character.id}`, this.character)
this.$emit('character-updated', this.character)
try {
this.$emit('update-property', prop, newValue)
this.cancelEditProp()
} catch (error) {
console.error('Failed to update property:', error)
alert('Fehler beim Speichern: ' + (error.response?.data?.error || error.message))
this.cancelEditProp()
}
},
getSelectOptions(prop) {
if (!this.datasheetOptions) return []
const optionMap = {
'gender': 'gender',
'rasse': 'races',
'origin': 'origins',
'social_class': 'social_classes',
'glaube': 'faiths',
'hand': 'handedness',
'spezialisierung': 'specializations'
}
return this.datasheetOptions[optionMap[prop]] || []
},
cancelEditProp() {
this.editingProp = null
this.editPropValue = ''
this.editPropType = 'text'
}
}
};
</script>
@@ -1,55 +0,0 @@
<template>
<div class="cd-view">
<div v-if="!isOwner" class="error-message">
<p>{{ $t('deleteChar.notAuthorized') }}</p>
</div>
<div v-else>
<p>Are you sure you want to delete {{ character.name }}?</p>
<button @click="deleteCharacter" class="btn btn-danger">Yes, Delete</button>
<button @click="$emit('cancel')" class="btn btn-secondary">Cancel</button>
</div>
</div>
</template>
<script>
import API from '../utils/api'
export default {
name: "DeleteCharView",
props: {
character: {
type: Object,
required: true
},
isOwner: {
type: Boolean,
default: false
}
},
emits: ['deleted', 'cancel'],
methods: {
async deleteCharacter() {
try {
const response = await API.delete(`/api/characters/${this.character.id}`)
if (response.status === 200 || response.status === 204) {
this.$emit('deleted')
this.$router.push('/dashboard')
} else {
console.error('Failed to delete character')
}
} catch (error) {
console.error('Error deleting character:', error)
}
}
}
}
</script>
<style>
/* All common styles moved to main.css */
</style>
-273
View File
@@ -1,273 +0,0 @@
<template>
<div class="fullwidth-container">
<div class="header-section">
<h2>{{ $t('EquipmentView') }}</h2>
<button v-if="isOwner" @click="openAddEquipmentDialog" class="btn-add-equipment">
{{ $t('equipment.add') }}
</button>
</div>
<div class="cd-list">
<table class="cd-table">
<thead>
<tr>
<th>{{ $t('equipment.name') }}</th>
<th>{{ $t('equipment.description') }}</th>
<th>{{ $t('equipment.weight') }}</th>
<th>{{ $t('equipment.value') }}</th>
<th>{{ $t('equipment.amount') }}</th>
<th>{{ $t('equipment.contained_in') }}</th>
<th>{{ $t('equipment.bonus') }}</th>
<th v-if="isOwner">{{ $t('equipment.actions') }}</th>
</tr>
</thead>
<tbody>
<template v-if="character.ausruestung && character.ausruestung.length > 0">
<tr v-for="equipment in character.ausruestung" :key="equipment.id">
<td>{{ equipment.name || '-' }}</td>
<td>{{ equipment.beschreibung || '-' }}</td>
<td>{{ equipment.gewicht || '-' }}</td>
<td>{{ equipment.wert || '-' }}</td>
<td>{{ equipment.anzahl || '-' }}</td>
<td>{{ equipment.beinhaltet_in || '-' }}</td>
<td>{{ equipment.bonus || '-' }}</td>
<td v-if="isOwner" class="action-cell">
<button @click="deleteEquipment(equipment)" class="btn-delete" title="Löschen">
🗑
</button>
</td>
</tr>
</template>
<template v-else>
<tr>
<td colspan="8" class="empty-state">{{ $t('equipment.noEquipment') }}</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Dialog für Ausrüstung hinzufügen -->
<div v-if="showAddDialog" class="modal-overlay" @click.self="closeDialog">
<div class="modal-content modal-fullscreen">
<div class="modal-header">
<h3>{{ $t('equipment.addEquipment') }}</h3>
<button @click="closeDialog" class="close-button">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>{{ $t('equipment.search') }}:</label>
<input
v-model="searchQuery"
type="text"
:placeholder="$t('equipment.searchPlaceholder')"
@input="filterEquipment"
/>
</div>
<div v-if="isLoading" class="loading">{{ $t('equipment.loading') }}</div>
<div v-else-if="filteredMasterEquipment.length > 0" class="equipment-list">
<div
v-for="equipment in filteredMasterEquipment"
:key="equipment.id"
class="equipment-item"
:class="{ selected: selectedEquipment && selectedEquipment.id === equipment.id }"
@click="selectEquipment(equipment)"
>
<div class="equipment-name">{{ equipment.name }}</div>
<div class="equipment-details">
{{ equipment.beschreibung || '-' }} |
{{ equipment.weight }}kg |
{{ equipment.value || '-' }}
</div>
</div>
</div>
<div v-else class="no-results">
{{ searchQuery ? $t('equipment.noResults') : $t('equipment.noMasterData') }}
</div>
<div v-if="selectedEquipment" class="selected-equipment-details">
<h4>{{ $t('equipment.selectedEquipment') }}</h4>
<p><strong>{{ $t('equipment.name') }}:</strong> {{ selectedEquipment.name }}</p>
<p><strong>{{ $t('equipment.description') }}:</strong> {{ selectedEquipment.beschreibung || '-' }}</p>
<p><strong>{{ $t('equipment.weight') }}:</strong> {{ selectedEquipment.weight }}kg</p>
<p><strong>{{ $t('equipment.value') }}:</strong> {{ selectedEquipment.value || '-' }}</p>
<div class="form-group">
<label>{{ $t('equipment.amount') }}:</label>
<input v-model.number="equipmentAmount" type="number" min="1" value="1" />
</div>
</div>
</div>
<div class="modal-footer">
<button @click="closeDialog" class="btn-cancel">{{ $t('equipment.cancel') }}</button>
<button
@click="addEquipment"
class="btn-confirm"
:disabled="!selectedEquipment || isSubmitting"
>
{{ isSubmitting ? $t('equipment.adding') : $t('equipment.add') }}
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* All styles moved to main.css */
/* Equipment-specific table header color override */
.cd-table th {
background-color: #1da766;
}
/* Fix modal footer visibility */
.modal-fullscreen {
display: flex;
flex-direction: column;
max-height: calc(100vh - 50px);
}
.modal-fullscreen .modal-body {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
.modal-fullscreen .modal-header,
.modal-fullscreen .modal-footer {
flex-shrink: 0;
}
</style>
<script>
import API from '@/utils/api'
export default {
name: "EquipmentView",
props: {
character: {
type: Object,
required: true
},
isOwner: {
type: Boolean,
default: false
}
},
data() {
return {
showAddDialog: false,
isLoading: false,
isSubmitting: false,
masterEquipment: [],
filteredMasterEquipment: [],
selectedEquipment: null,
searchQuery: '',
equipmentAmount: 1
}
},
created() {
this.$api = API
},
methods: {
async openAddEquipmentDialog() {
this.showAddDialog = true
this.isLoading = true
try {
const response = await this.$api.get('/api/maintenance/equipment')
this.masterEquipment = response.data || []
this.filteredMasterEquipment = this.masterEquipment
} catch (error) {
console.error('Fehler beim Laden der Ausrüstungs-Stammdaten:', error)
alert(this.$t('equipment.loadError') + ': ' + (error.response?.data?.error || error.message))
} finally {
this.isLoading = false
}
},
filterEquipment() {
const query = this.searchQuery.toLowerCase().trim()
if (!query) {
this.filteredMasterEquipment = this.masterEquipment
} else {
this.filteredMasterEquipment = this.masterEquipment.filter(equipment =>
equipment.name.toLowerCase().includes(query) ||
(equipment.beschreibung && equipment.beschreibung.toLowerCase().includes(query))
)
}
},
selectEquipment(equipment) {
this.selectedEquipment = equipment
},
async addEquipment() {
if (!this.selectedEquipment) {
alert(this.$t('equipment.pleaseSelect'))
return
}
this.isSubmitting = true
try {
const equipmentData = {
character_id: this.character.id,
name: this.selectedEquipment.name,
beschreibung: this.selectedEquipment.beschreibung || '',
gewicht: this.selectedEquipment.weight || 0,
wert: this.selectedEquipment.value || 0,
anzahl: this.equipmentAmount,
bonus: this.selectedEquipment.bonus || 0,
beinhaltet_in: '',
contained_in: 0,
container_type: ''
}
await this.$api.post('/api/equipment', equipmentData)
alert(this.$t('equipment.addSuccess'))
this.closeDialog()
this.$emit('character-updated')
} catch (error) {
console.error('Fehler beim Hinzufügen der Ausrüstung:', error)
alert(this.$t('equipment.addError') + ': ' + (error.response?.data?.error || error.message))
} finally {
this.isSubmitting = false
}
},
async deleteEquipment(equipment) {
if (!confirm(this.$t('equipment.confirmDelete').replace('{name}', equipment.name))) {
return
}
try {
await this.$api.delete(`/api/equipment/${equipment.id}`)
alert(this.$t('equipment.deleteSuccess'))
this.$emit('character-updated')
} catch (error) {
console.error('Fehler beim Löschen der Ausrüstung:', error)
alert(this.$t('equipment.deleteError') + ': ' + (error.response?.data?.error || error.message))
}
},
closeDialog() {
this.showAddDialog = false
this.selectedEquipment = null
this.searchQuery = ''
this.equipmentAmount = 1
this.filteredMasterEquipment = []
this.masterEquipment = []
}
}
}
</script>
-346
View File
@@ -1,346 +0,0 @@
<template>
<div class="fullwidth-container">
<!-- Erfahrungspunkte -->
<div class="experience-section">
<div class="section-header">
<h4>{{ $t('experience.experience_points') }}</h4>
</div>
<div class="resource-display">
<div class="resource-card">
<span class="resource-icon"></span>
<div class="resource-info">
<div class="resource-label">{{ $t('experience.available_ep') }}</div>
<div class="resource-amount">{{ character.erfahrungsschatz?.ep || 0 }} EP</div>
</div>
</div>
<div v-if="isOwner" class="form-row control-row">
<div class="form-group">
<input
v-model.number="experienceAmount"
type="number"
class="form-control amount-input"
placeholder="Anzahl EP"
min="1"
/>
</div>
<div class="button-group">
<button @click="addExperience" class="btn btn-success" :disabled="!experienceAmount || experienceAmount <= 0 || isLoading">
<span v-if="isLoading"></span>
<span v-else>+ Hinzufügen</span>
</button>
<button @click="removeExperience" class="btn btn-danger" :disabled="!experienceAmount || experienceAmount <= 0 || isLoading">
<span v-if="isLoading"></span>
<span v-else>- Entfernen</span>
</button>
</div>
</div>
</div>
</div>
<!-- Vermögen -->
<div class="wealth-section">
<div class="section-header">
<h4>{{ $t('experience.wealth') }}</h4>
</div>
<div class="resource-display">
<div class="resource-card">
<span class="resource-icon">💰</span>
<div class="resource-info">
<div class="resource-label">{{ $t('experience.gold_coins') }}</div>
<div class="resource-amount">{{ character.vermoegen?.goldstücke || 0 }} GS</div>
</div>
</div>
<div v-if="isOwner" class="form-row control-row">
<div class="form-group">
<input
v-model.number="goldAmount"
type="number"
class="form-control amount-input"
placeholder="Anzahl GS"
min="1"
/>
</div>
<div class="button-group">
<button @click="addGold" class="btn btn-success" :disabled="!goldAmount || goldAmount <= 0 || isLoading">
<span v-if="isLoading"></span>
<span v-else>+ Hinzufügen</span>
</button>
<button @click="removeGold" class="btn btn-danger" :disabled="!goldAmount || goldAmount <= 0 || isLoading">
<span v-if="isLoading"></span>
<span v-else>- Entfernen</span>
</button>
</div>
</div>
<!-- Total wealth display -->
<div class="resource-card wealth-total">
<span class="resource-icon">💎</span>
<div class="resource-info">
<div class="resource-label">{{ $t('experience.total_in_gs') }}</div>
<div class="resource-amount total-amount">{{ totalWealthInGS }} GS</div>
</div>
</div>
</div>
</div>
<!-- Audit Log -->
<AuditLogView
ref="auditLog"
v-if="character"
:character="character"
/>
</div>
</template>
<style>
/* All common styles moved to main.css */
.experience-section,
.wealth-section {
margin-bottom: 30px;
}
.control-row {
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #e9ecef;
align-items: center;
gap: 15px;
}
.button-group {
display: flex;
gap: 8px;
}
.amount-input {
width: 120px;
text-align: right;
font-weight: bold;
}
.wealth-total {
margin-top: 15px;
border: 2px solid #007bff;
background: #f0f8ff;
}
.total-amount {
color: #007bff;
font-size: 1.1em;
font-weight: bold;
}
</style>
<script>
import API from '@/utils/api'
import AuditLogView from './AuditLogView.vue'
export default {
name: "ExperianceView",
components: {
AuditLogView
},
props: {
character: {
type: Object,
required: true
},
isOwner: {
type: Boolean,
default: false
}
},
data() {
return {
experienceAmount: null,
goldAmount: null,
isLoading: false,
testMode: false // Für Debugging - setzt auf true um Backend zu umgehen
};
},
created() {
this.$api = API;
},
computed: {
totalWealthInGS() {
const vermoegen = this.character.vermoegen || {};
const goldstücke = vermoegen.goldstücke || 0; // GS
const silberstücke = vermoegen.silberstücke || 0; // SS
const kupferstücke = vermoegen.kupferstücke || 0; // KS
// Midgard Währungsumrechnung: 1 GS = 10 SS = 10 KS
// Alles in Goldstücke umrechnen
return goldstücke + Math.floor(silberstücke / 10) + Math.floor(kupferstücke / 10);
}
},
methods: {
refreshAuditLog() {
// Refresh the audit log after EP or gold changes
if (this.$refs.auditLog && this.$refs.auditLog.loadAuditLog) {
this.$refs.auditLog.loadAuditLog();
}
},
async addExperience() {
if (!this.experienceAmount || this.experienceAmount <= 0 || this.isLoading) return;
this.isLoading = true;
try {
const currentEP = this.character.erfahrungsschatz?.ep || 0;
const newEP = currentEP + this.experienceAmount;
await this.updateExperience(newEP);
this.experienceAmount = null;
} catch (error) {
console.error('Fehler beim Hinzufügen der Erfahrungspunkte:', error);
} finally {
this.isLoading = false;
}
},
async removeExperience() {
if (!this.experienceAmount || this.experienceAmount <= 0 || this.isLoading) return;
this.isLoading = true;
try {
const currentEP = this.character.erfahrungsschatz?.ep || 0;
const newEP = Math.max(0, currentEP - this.experienceAmount);
await this.updateExperience(newEP);
this.experienceAmount = null;
} catch (error) {
console.error('Fehler beim Entfernen der Erfahrungspunkte:', error);
} finally {
this.isLoading = false;
}
},
async addGold() {
if (!this.goldAmount || this.goldAmount <= 0 || this.isLoading) return;
this.isLoading = true;
try {
const currentGold = this.character.vermoegen?.goldstücke || 0;
const newGold = currentGold + this.goldAmount;
await this.updateGold(newGold);
this.goldAmount = null;
} catch (error) {
console.error('Fehler beim Hinzufügen der Goldstücke:', error);
} finally {
this.isLoading = false;
}
},
async removeGold() {
if (!this.goldAmount || this.goldAmount <= 0 || this.isLoading) return;
this.isLoading = true;
try {
const currentGold = this.character.vermoegen?.goldstücke || 0;
const newGold = Math.max(0, currentGold - this.goldAmount);
await this.updateGold(newGold);
this.goldAmount = null;
} catch (error) {
console.error('Fehler beim Entfernen der Goldstücke:', error);
} finally {
this.isLoading = false;
}
},
async updateExperience(newValue) {
try {
console.log('Updating experience to:', newValue);
if (!this.testMode) {
// API-Call zum Speichern der Erfahrungspunkte
const response = await this.$api.put(`/api/characters/${this.character.id}/experience`, {
experience_points: newValue,
reason: "manual",
notes: `Manual adjustment: ${this.experienceAmount > 0 ? 'Added' : 'Removed'} ${Math.abs(this.experienceAmount || 0)} EP`
});
console.log('Experience update response:', response.data);
} else {
console.log('Test mode - skipping API call');
// Simuliere einen kurzen delay
await new Promise(resolve => setTimeout(resolve, 500));
}
// Direkte Aktualisierung der lokalen Daten
if (this.character.erfahrungsschatz) {
this.character.erfahrungsschatz.value = newValue;
} else {
this.$set(this.character, 'erfahrungsschatz', { value: newValue });
}
// Refresh the audit log to show the change
this.refreshAuditLog();
// Emit event to parent component to refresh character data
this.$emit('character-updated');
} catch (error) {
console.error('Fehler beim Speichern der Erfahrungspunkte:', error);
console.error('Error details:', error.response);
if (error.response?.status === 401) {
alert('Authentifizierung fehlgeschlagen. Bitte loggen Sie sich erneut ein.');
} else {
alert('Fehler beim Speichern der Erfahrungspunkte: ' + (error.response?.data?.error || error.message));
}
throw error; // Re-throw to be caught by calling function
}
},
async updateGold(newValue) {
try {
console.log('Updating gold to:', newValue);
if (!this.testMode) {
// API-Call zum Speichern der Goldstücke
const response = await this.$api.put(`/api/characters/${this.character.id}/wealth`, {
goldstücke: newValue,
reason: "manual",
notes: `Manual adjustment: ${this.goldAmount > 0 ? 'Added' : 'Removed'} ${Math.abs(this.goldAmount || 0)} GS`
});
console.log('Gold update response:', response.data);
} else {
console.log('Test mode - skipping API call');
// Simuliere einen kurzen delay
await new Promise(resolve => setTimeout(resolve, 500));
}
// Direkte Aktualisierung der lokalen Daten
if (this.character.vermoegen) {
this.character.vermoegen.goldstücke = newValue;
} else {
this.$set(this.character, 'vermoegen', { goldstücke: newValue, silberstücke: 0, kupferstücke: 0 });
}
// Refresh the audit log to show the change
this.refreshAuditLog();
// Emit event to parent component to refresh character data
this.$emit('character-updated');
} catch (error) {
console.error('Fehler beim Speichern der Goldstücke:', error);
console.error('Error details:', error.response);
if (error.response?.status === 401) {
alert('Authentifizierung fehlgeschlagen. Bitte loggen Sie sich erneut ein.');
} else {
alert('Fehler beim Speichern der Goldstücke: ' + (error.response?.data?.error || error.message));
}
throw error; // Re-throw to be caught by calling function
}
}
}
};
</script>
-225
View File
@@ -1,225 +0,0 @@
<template>
<div v-if="showDialog" class="modal-overlay" @click.self="closeDialog">
<div class="modal-content">
<div class="modal-header">
<h3>{{ $t('export.title') }}</h3>
<button @click="closeDialog" class="close-button">&times;</button>
</div>
<div class="modal-body">
<div v-if="isExporting" class="loading-overlay">
<div class="spinner"></div>
<p>{{ $t('export.generating') }}</p>
</div>
<div class="form-group">
<label>{{ $t('export.selectFormat') }}:</label>
<select v-model="selectedFormat" class="template-select" :disabled="isExporting">
<option value="">{{ $t('export.pleaseSelectFormat') }}</option>
<option value="pdf">{{ $t('export.formatPDF') }}</option>
<option value="vtt">{{ $t('export.formatVTT') }}</option>
<option value="bamort">{{ $t('export.formatBaMoRT') }}</option>
</select>
</div>
<div v-if="selectedFormat === 'pdf'" class="form-group">
<label>{{ $t('export.selectTemplate') }}:</label>
<select v-model="selectedTemplate" class="template-select" :disabled="isExporting">
<option value="">{{ $t('export.pleaseSelectTemplate') }}</option>
<option v-for="template in templates" :key="template.id" :value="template.id">
{{ template.name }}
</option>
</select>
</div>
<div v-if="selectedFormat === 'pdf'" class="form-group">
<label class="checkbox-label">
<input type="checkbox" v-model="showUserName" :disabled="isExporting">
{{ $t('export.showUserName') }}
</label>
</div>
</div>
<div class="modal-footer">
<button @click="performExport" class="btn-primary btn-save" :disabled="!canExport || isExporting">
<span v-if="!isExporting">{{ $t('export.export') }}</span>
<span v-else>{{ $t('export.exporting') }}</span>
</button>
<button @click="closeDialog" class="btn-cancel" :disabled="isExporting">
{{ $t('common.cancel') }}
</button>
</div>
</div>
</div>
</template>
<style>
/* All common styles moved to main.css - no component-specific styles needed */
</style>
<script>
import API from '../utils/api'
export default {
name: "ExportDialog",
props: {
characterId: {
type: [String, Number],
required: true
},
showDialog: {
type: Boolean,
default: false
}
},
data() {
return {
templates: [],
selectedFormat: "",
selectedTemplate: "",
showUserName: false,
isExporting: false
}
},
computed: {
canExport() {
if (!this.selectedFormat) return false
if (this.selectedFormat === 'pdf' && !this.selectedTemplate) return false
return true
}
},
async created() {
await this.loadTemplates()
},
methods: {
async loadTemplates() {
try {
const response = await API.get('/api/pdf/templates')
this.templates = response.data
// Auto-select first template if available
if (this.templates.length > 0) {
this.selectedTemplate = this.templates[0].id
}
} catch (error) {
console.error('Failed to load templates:', error)
}
},
async performExport() {
if (!this.selectedFormat) {
alert(this.$t('export.pleaseSelectFormat'))
return
}
if (this.selectedFormat === 'pdf') {
await this.exportToPDF()
} else if (this.selectedFormat === 'vtt') {
await this.exportToVTT()
} else if (this.selectedFormat === 'bamort') {
await this.exportToBaMoRT()
}
},
async exportToPDF() {
if (!this.selectedTemplate) {
alert(this.$t('export.pleaseSelectTemplate'))
return
}
this.isExporting = true
try {
// Build URL parameters
const params = new URLSearchParams({
template: this.selectedTemplate
})
if (this.showUserName) {
params.append('showUserName', 'true')
}
// Get filename from export API (saves PDF to file)
const response = await API.get(`/api/pdf/export/${this.characterId}`, {
params: Object.fromEntries(params)
})
const filename = response.data.filename
if (!filename) {
throw new Error('No filename returned from export')
}
// Open PDF in new window using file endpoint
const pdfUrl = `${API.defaults.baseURL}/api/pdf/file/${filename}`
window.open(pdfUrl, '_blank')
// Emit success event and close dialog
this.$emit('export-success')
this.closeDialog()
} catch (error) {
console.error('Failed to export PDF:', error)
alert(this.$t('export.exportFailed') + ': ' + (error.response?.data?.error || error.message))
} finally {
this.isExporting = false
}
},
async exportToVTT() {
this.isExporting = true
try {
// Get VTT data and trigger download
const response = await API.get(`/api/importer/export/vtt/${this.characterId}/file`, {
responseType: 'blob'
})
// Create download link
const blob = new Blob([response.data], { type: 'application/json' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `character_${this.characterId}_vtt.json`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
this.$emit('export-success')
this.closeDialog()
} catch (error) {
console.error('Failed to export VTT:', error)
alert(this.$t('export.exportFailed') + ': ' + (error.response?.data?.error || error.message))
} finally {
this.isExporting = false
}
},
async exportToBaMoRT() {
this.isExporting = true
try {
// Get BaMoRT JSON data and trigger download
const response = await API.get(`/api/transfer/download/${this.characterId}`, {
responseType: 'blob'
})
// Create download link
const blob = new Blob([response.data], { type: 'application/json' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `character_${this.characterId}_bamort.json`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
this.$emit('export-success')
this.closeDialog()
} catch (error) {
console.error('Failed to export BaMoRT format:', error)
alert(this.$t('export.exportFailed') + ': ' + (error.response?.data?.error || error.message))
} finally {
this.isExporting = false
}
},
closeDialog() {
this.$emit('update:showDialog', false)
}
}
}
</script>
@@ -1,383 +0,0 @@
<template>
<div class="image-upload-container">
<button @click="showDialog = true" class="btn-upload">
{{ $t('character.uploadImage') }}
</button>
<div v-if="showDialog" class="modal-overlay" @click.self="closeDialog">
<div class="modal-content image-upload-modal">
<div class="modal-header">
<h3>{{ $t('character.uploadImage') }}</h3>
<button @click="closeDialog" class="close-button">&times;</button>
</div>
<div class="modal-body">
<div v-if="!imageLoaded" class="upload-area">
<input
type="file"
ref="fileInput"
@change="handleFileSelect"
accept="image/*"
style="display: none"
/>
<button @click="$refs.fileInput.click()" class="btn-select-file">
{{ $t('character.selectImage') }}
</button>
</div>
<div v-else class="crop-area">
<div class="crop-controls">
<label>
<input type="radio" v-model="cropShape" value="rect" />
{{ $t('character.cropRect') }}
</label>
<label>
<input type="radio" v-model="cropShape" value="round" />
{{ $t('character.cropRound') }}
</label>
</div>
<div class="canvas-container">
<canvas
ref="canvas"
@mousedown="startDrag"
@mousemove="drag"
@mouseup="endDrag"
@mouseleave="endDrag"
></canvas>
</div>
<div class="preview-container">
<h4>{{ $t('character.preview') }}</h4>
<canvas ref="previewCanvas" width="400" height="400"></canvas>
</div>
</div>
</div>
<div class="modal-footer">
<button @click="closeDialog" class="btn-cancel">{{ $t('cancel') }}</button>
<button v-if="imageLoaded" @click="resetImage" class="btn-secondary">
{{ $t('character.changeImage') }}
</button>
<button
v-if="imageLoaded"
@click="uploadImage"
class="btn-primary"
:disabled="isUploading"
>
<span v-if="!isUploading">{{ $t('character.saveImage') }}</span>
<span v-else>{{ $t('uploading') }}...</span>
</button>
</div>
</div>
</div>
</div>
</template>
<style>
/* All common styles moved to main.css */
/* ImageUploadCropper specific styles */
.btn-upload {
padding: 8px 16px;
background-color: var(--primary-color);
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-upload:hover {
opacity: 0.9;
}
.image-upload-modal {
min-width: 700px;
max-width: 90vw;
}
.upload-area {
text-align: center;
padding: 40px;
}
.btn-select-file {
padding: 12px 24px;
background-color: var(--primary-color);
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
.crop-area {
display: flex;
flex-direction: column;
gap: 20px;
}
.crop-controls {
display: flex;
gap: 20px;
justify-content: center;
}
.crop-controls label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.canvas-container {
display: flex;
justify-content: center;
border: 1px solid #ccc;
background-color: #f5f5f5;
overflow: auto;
max-height: 500px;
}
.canvas-container canvas {
cursor: crosshair;
}
.preview-container {
text-align: center;
}
.preview-container h4 {
margin-bottom: 10px;
}
.preview-container canvas {
border: 1px solid #ccc;
background-color: white;
}
</style>
<script>
import API from '../utils/api'
export default {
name: 'ImageUploadCropper',
props: {
characterId: {
type: [String, Number],
required: true
}
},
data() {
return {
showDialog: false,
imageLoaded: false,
isUploading: false,
cropShape: 'rect',
image: null,
cropStart: null,
cropEnd: null,
isDragging: false,
cropWidth: 400,
cropHeight: 400
}
},
methods: {
handleFileSelect(event) {
const file = event.target.files[0]
if (!file) return
const reader = new FileReader()
reader.onload = (e) => {
const img = new Image()
img.onload = () => {
this.image = img
this.imageLoaded = true
this.$nextTick(() => {
this.initCanvas()
})
}
img.src = e.target.result
}
reader.readAsDataURL(file)
},
initCanvas() {
const canvas = this.$refs.canvas
const ctx = canvas.getContext('2d')
canvas.width = this.image.width
canvas.height = this.image.height
ctx.drawImage(this.image, 0, 0)
// Initialize crop area in center
this.cropStart = {
x: Math.max(0, (this.image.width - this.cropWidth) / 2),
y: Math.max(0, (this.image.height - this.cropHeight) / 2)
}
this.cropEnd = {
x: this.cropStart.x + this.cropWidth,
y: this.cropStart.y + this.cropHeight
}
this.drawCropOverlay()
this.updatePreview()
},
drawCropOverlay() {
const canvas = this.$refs.canvas
const ctx = canvas.getContext('2d')
// Redraw image
ctx.drawImage(this.image, 0, 0)
// Draw semi-transparent overlay
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'
ctx.fillRect(0, 0, canvas.width, canvas.height)
const width = this.cropEnd.x - this.cropStart.x
const height = this.cropEnd.y - this.cropStart.y
// Clear crop area
ctx.clearRect(this.cropStart.x, this.cropStart.y, width, height)
ctx.drawImage(
this.image,
this.cropStart.x, this.cropStart.y, width, height,
this.cropStart.x, this.cropStart.y, width, height
)
// Draw border
ctx.strokeStyle = '#00ff00'
ctx.lineWidth = 2
if (this.cropShape === 'round') {
ctx.beginPath()
const centerX = this.cropStart.x + width / 2
const centerY = this.cropStart.y + height / 2
const radius = Math.min(width, height) / 2
ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI)
ctx.stroke()
} else {
ctx.strokeRect(this.cropStart.x, this.cropStart.y, width, height)
}
},
updatePreview() {
const previewCanvas = this.$refs.previewCanvas
const ctx = previewCanvas.getContext('2d')
const width = this.cropEnd.x - this.cropStart.x
const height = this.cropEnd.y - this.cropStart.y
ctx.clearRect(0, 0, 400, 400)
if (this.cropShape === 'round') {
ctx.save()
ctx.beginPath()
ctx.arc(200, 200, 200, 0, 2 * Math.PI)
ctx.clip()
}
ctx.drawImage(
this.image,
this.cropStart.x, this.cropStart.y, width, height,
0, 0, 400, 400
)
if (this.cropShape === 'round') {
ctx.restore()
}
},
startDrag(event) {
const canvas = this.$refs.canvas
const rect = canvas.getBoundingClientRect()
// Calculate mouse position on the actual canvas (accounting for scaling)
const scaleX = canvas.width / rect.width
const scaleY = canvas.height / rect.height
this.cropStart = {
x: (event.clientX - rect.left) * scaleX,
y: (event.clientY - rect.top) * scaleY
}
this.isDragging = true
},
drag(event) {
if (!this.isDragging) return
const canvas = this.$refs.canvas
const rect = canvas.getBoundingClientRect()
// Calculate mouse position on the actual canvas (accounting for scaling)
const scaleX = canvas.width / rect.width
const scaleY = canvas.height / rect.height
this.cropEnd = {
x: (event.clientX - rect.left) * scaleX,
y: (event.clientY - rect.top) * scaleY
}
// Ensure minimum size
const minSize = 50
if (Math.abs(this.cropEnd.x - this.cropStart.x) < minSize ||
Math.abs(this.cropEnd.y - this.cropStart.y) < minSize) {
return
}
this.drawCropOverlay()
this.updatePreview()
},
endDrag() {
this.isDragging = false
},
async uploadImage() {
this.isUploading = true
try {
// Get cropped image as base64
const previewCanvas = this.$refs.previewCanvas
const croppedImage = previewCanvas.toDataURL('image/png')
await API.put(`/api/characters/${this.characterId}/image`, {
image: croppedImage
})
this.$emit('image-updated', croppedImage)
this.closeDialog()
//alert(this.$t('character.imageUploadSuccess'))
} catch (error) {
console.error('Failed to upload image:', error)
alert(this.$t('character.imageUploadError') + ': ' + (error.response?.data?.error || error.message))
} finally {
this.isUploading = false
}
},
resetImage() {
this.imageLoaded = false
this.image = null
this.cropStart = null
this.cropEnd = null
if (this.$refs.fileInput) {
this.$refs.fileInput.value = ''
}
},
closeDialog() {
this.showDialog = false
this.resetImage()
}
},
watch: {
cropShape() {
if (this.imageLoaded) {
this.drawCropOverlay()
this.updatePreview()
}
}
}
}
</script>
-118
View File
@@ -1,118 +0,0 @@
<template>
<div class="character-details">
<div v-if="loading">Loading...</div>
<div v-else>
<!-- Submenu Content -->
<component
:is="currentView"
:mdata="mdata"
/>
</div>
<!-- Submenu -->
<div class="submenu">
<button
v-for="menu in menus"
:key="menu.id"
:class="{ active: currentView === menu.component }"
@click="changeView(menu.component)"
>
{{ $t( 'maintmenu.'+ menu.name ) }}
</button>
</div>
</div>
</template>
<style>
</style>
<script>
import API from '../utils/api'
import SkillView from "./maintenance/SkillView.vue"; // Component for character history
import SpellView from "./maintenance/SpellView.vue"; // Component for character history
import EquipmentView from "./maintenance/EquipmentView.vue"; // Component for character equipment
import WeaponView from "./maintenance/WeaponView.vue"; // Component for character history
import WeaponSkillView from "./maintenance/WeaponSkillView.vue"; // Component for character equipment
import BelieveView from "./maintenance/BelieveView.vue"; // Component for believes maintenance
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 {
name: "Maintenance",
//props: ["id"], // Receive the route parameter as a prop
components: {
SkillView,
SpellView,
EquipmentView,
WeaponView,
WeaponSkillView,
BelieveView,
GameSystemView,
LitSourceView,
MiscLookupView,
SkillImprovementCostView,
LearningCostView,
},
data() {
return {
mdata: {
skills: [],
skillcategories: [],
weaponskills: [],
spells: [],
spellcategories: [],
equipment:[],
weapons: [],
weaponskills: [],
},
loading: true,
currentView: "SkillView", // Default view
lastView: "SkillView",
menus: [
{ id: 0, name: "skill", component: "SkillView" },
{ id: 1, name: "spell", component: "SpellView" },
{ id: 2, name: "equipment", component: "EquipmentView" },
{ id: 3, name: "weapon", component: "WeaponView" },
{ id: 4, name: "weaponskill", component: "WeaponSkillView" },
{ id: 5, name: "believe", component: "BelieveView" },
{ id: 6, name: "gamesystem", component: "GameSystemView" },
{ id: 7, name: "litsource", component: "LitSourceView" },
{ id: 8, name: "misc", component: "MiscLookupView" },
{ id: 9, name: "skillimprovement", component: "SkillImprovementCostView" },
{ id: 10, name: "learningcost", component: "LearningCostView" },
],
};
},
async created() {
try {
const token = localStorage.getItem('token')
const response = await API.get(`/api/maintenance`, {
headers: { Authorization: `Bearer ${token}` },
})
this.mdata= response.data
} catch (error) {
console.error('Failed to load data:', error)
} finally {
this.loading = false
}
/*
this.skills = response.data['skills']
this.weaponskills = response.data["weaponskills"]
this.spells = response.data["spells"]
this.equipment = response.data["equipment"]
this.weapons = response.data["weapons"]
*/
},
methods: {
changeView(view) {
this.lastView = this.currentView;
this.currentView = view;
}
},
};
</script>
+6 -11
View File
@@ -16,21 +16,16 @@
</ul>
</li>
<!-- Characters Dropdown (only when logged in) -->
<li v-if="isLoggedIn" class="dropdown" @mouseenter="openMenu('char')" @mouseleave="closeMenu('char')">
<span class="dropdown-trigger">{{ $t('menu.Characters') }} </span>
<ul v-show="showCharMenu" class="dropdown-menu" @mouseenter="openMenu('char')" @mouseleave="closeMenu('char')">
<li><router-link to="/dashboard" @click="closeAllMenus">{{ $t('menu.Dashboard') }}</router-link></li>
<li><router-link to="/upload" @click="closeAllMenus">{{ $t('menu.ImportData') }}</router-link></li>
</ul>
<!-- Dashboard (only when logged in) -->
<li v-if="isLoggedIn">
<router-link to="/dashboard" active-class="active">{{ $t('menu.Dashboard') }}</router-link>
</li>
<!-- Admin Dropdown (only for maintainers/admins) -->
<li v-if="isLoggedIn && (isMaintainer || isAdmin)" class="dropdown" @mouseenter="openMenu('admin')" @mouseleave="closeMenu('admin')">
<!-- Admin Dropdown (only for admins) -->
<li v-if="isLoggedIn && isAdmin" class="dropdown" @mouseenter="openMenu('admin')" @mouseleave="closeMenu('admin')">
<span class="dropdown-trigger">{{ $t('menu.Admin') }} </span>
<ul v-show="showAdminMenu" class="dropdown-menu" @mouseenter="openMenu('admin')" @mouseleave="closeMenu('admin')">
<li v-if="isMaintainer"><router-link to="/maintenance" @click="closeAllMenus">{{ $t('menu.Maintenance') }}</router-link></li>
<li v-if="isAdmin"><router-link to="/users" @click="closeAllMenus">{{ $t('menu.UserManagement') }}</router-link></li>
<li><router-link to="/users" @click="closeAllMenus">{{ $t('menu.UserManagement') }}</router-link></li>
</ul>
</li>
@@ -1,748 +0,0 @@
<template>
<div v-if="isVisible" class="modal-overlay" @click.self="closeDialog">
<div class="modal-content modal-fullscreen">
<h3>{{ skill?.name }} verbessern</h3>
<!-- Aktuelle Ressourcen -->
<div class="current-resources">
<div class="resource-display-card">
<span class="resource-icon"></span>
<div class="resource-info">
<div class="resource-label">Erfahrungspunkte</div>
<div class="resource-amount">{{ character.erfahrungsschatz?.ep || 0 }} EP</div>
<div class="resource-remaining">
<small :class="{ 'text-warning': remainingEP < 50, 'text-danger': remainingEP <= 0 }">
Verbleibend: {{ remainingEP }} EP
</small>
</div>
</div>
</div>
<div class="resource-display-card">
<span class="resource-icon">💰</span>
<div class="resource-info">
<div class="resource-label">Gold</div>
<div class="resource-amount">{{ character.vermoegen?.goldstücke || 0 }} GS</div>
<div class="resource-remaining">
<small>
Verwendet: {{ totalGoldCost || 0 }} GS |
Verbleibend: {{ Math.max(0, (character.vermoegen?.goldstücke || 0) - (totalGoldCost || 0)) }} GS
</small>
</div>
<div class="resource-remaining">
<small :class="{ 'text-warning': remainingGold < 20, 'text-danger': remainingGold <= 0 }">
Nach Lernen: {{ remainingGold }} GS
</small>
</div>
</div>
</div>
<div class="resource-display-card">
<span class="resource-icon">📝</span>
<div class="resource-info">
<div class="resource-label">Praxispunkte</div>
<div class="resource-amount">{{ skill?.pp || 0 }} PP</div>
<div class="resource-remaining">
<small>
Verwendet: {{ totalPPCost || 0 }} PP |
Verbleibend: {{ Math.max(0, (skill?.pp || 0) - (totalPPCost || 0)) }} PP
</small>
</div>
<div class="resource-remaining">
<small :class="{ 'text-warning': remainingPP < 5, 'text-danger': remainingPP <= 0 }">
Nach Lernen: {{ remainingPP }} PP
</small>
</div>
</div>
</div>
</div> <!-- Belohnungsart, PP-Eingabe und Gold-Eingabe nebeneinander -->
<div class="form-group form-row">
<div class="form-col form-col-main">
<label>Lernen als Belohnung:</label>
<select v-model="selectedRewardType" :disabled="isLoadingRewardTypes">
<option value="" disabled>
{{ isLoadingRewardTypes ? 'Lade Belohnungsarten...' : 'Belohnungsart wählen' }}
</option>
<option
v-for="rewardType in availableRewardTypes"
:key="rewardType.value"
:value="rewardType.value"
>
{{ rewardType.label }}
</option>
</select>
</div>
<div class="form-col form-col-input">
<label>Praxispunkte verwenden:</label>
<input
v-model.number="ppUsed"
type="number"
min="0"
:max="skill?.pp || 0"
placeholder="PP verwenden"
@input="updatePPUsage"
/>
<small class="help-text">
{{ ppUsed || 0 }} / {{ skill?.pp || 0 }} PP
</small>
</div>
<div class="form-col form-col-input">
<label>Goldstücke verwenden:</label>
<input
v-model.number="goldUsed"
type="number"
min="0"
:max="character.vermoegen?.goldstücke || 0"
placeholder="GS verwenden"
@input="updateGoldUsage"
/>
<small class="help-text">
{{ goldUsed || 0 }} / {{ character.vermoegen?.goldstücke || 0 }} GS
</small>
</div>
</div>
<!-- Lernbare Stufen -->
<div class="form-group">
<label>Lernbare Stufen (mehrere auswählbar):</label>
<div v-if="selectedLevels.length > 0" class="selection-summary">
<strong>Ausgewählt:</strong> {{ skill?.fertigkeitswert || 0 }} {{ finalTargetLevel }}
({{ selectedLevels.length }} Level{{ selectedLevels.length !== 1 ? 's' : '' }})
<br>
<span class="cost-summary">
Gesamtkosten:
<span v-if="selectedRewardType === 'default'">{{ totalCost }} EP + {{ totalGoldCost }} GS</span>
<span v-else-if="selectedRewardType === 'noGold'">{{ totalCost }} EP</span>
<span v-else-if="selectedRewardType === 'halveepnoGold'">{{ Math.floor(totalCost / 2) }} EP</span>
<span v-else-if="selectedRewardType === 'pp'">{{ totalPPCost }} PP</span>
<span v-else-if="selectedRewardType === 'mixed'">{{ totalCost }} EP + {{ totalPPCost }} PP</span>
<span v-else>{{ totalCost }} EP + {{ totalGoldCost }} GS</span>
</span>
</div>
<div class="learning-levels">
<div
v-for="level in availableLevels"
:key="level.targetLevel"
class="level-option"
:class="{
selected: selectedLevels.includes(level.targetLevel),
disabled: !level.canAfford,
'in-sequence': isInSelectedSequence(level.targetLevel)
}"
@click="selectLevel(level)"
>
<div class="level-header">
<span class="level-target">{{ level.startLevel }} {{ level.targetLevel }}</span>
<span class="level-cost" v-if="selectedRewardType === 'default'">{{ level.epCost }} EP + {{ level.goldCost }} GS</span>
<span class="level-cost" v-else-if="selectedRewardType === 'noGold'">{{ level.epCost }} EP</span>
<span class="level-cost" v-else-if="selectedRewardType === 'halveepnoGold'">{{ Math.floor(level.epCost / 2) }} EP</span>
<span class="level-cost" v-else-if="selectedRewardType === 'pp'">{{ level.ppCost }} PP</span>
<span class="level-cost" v-else-if="selectedRewardType === 'mixed'">{{ level.epCost }} EP + {{ level.ppUsed || 0 }} PP</span>
<span class="level-cost" v-else>{{ level.epCost }} EP + {{ level.goldCost }} GS</span>
</div>
<div class="level-details" v-if="selectedRewardType === 'mixed'">
<small>PP verwenden: {{ level.ppUsed }} / {{ skill?.pp || 0 }}</small>
</div>
</div>
</div>
</div>
<!-- Notizen -->
<div class="form-group">
<label>Notizen (optional):</label>
<textarea v-model="notes" placeholder="Zusätzliche Notizen zum Lernvorgang..."></textarea>
</div>
<div class="modal-actions">
<button
@click="executeDetailedLearning"
class="btn-confirm"
:disabled="!selectedLevels.length || !canAffordSelectedLevels || isLoading"
>
{{ isLoading ? 'Wird gelernt...' : `${selectedLevels.length > 1 ? selectedLevels.length + ' Level' : '1 Level'} jetzt lernen` }}
</button>
<button @click="closeDialog" class="btn-cancel" :disabled="isLoading">Abbrechen</button>
</div>
</div>
</div>
</template>
<style scoped>
/* Component-specific styles - common styles are in main.css */
.learning-levels {
max-height: 200px;
}
</style>
<script>
import API from '@/utils/api'
export default {
name: 'SkillImproveDialog',
props: {
character: {
type: Object,
required: true
},
skill: {
type: Object,
default: null,
required: true
},
isVisible: {
type: Boolean,
default: false
},
learningType: {
type: String,
default: 'improve', // 'improve', 'learn', 'spell'
validator: value => ['improve', 'learn', 'spell'].includes(value)
}
},
emits: ['close', 'skill-updated', 'auth-error'],
data() {
return {
selectedRewardType: '',
selectedLevels: [], // Array von ausgewählten Leveln
ppUsed: 0,
goldUsed: 0,
notes: '',
availableLevels: [],
availableRewardTypes: [],
isLoading: false,
isLoadingRewardTypes: false
};
},
computed: {
canAffordSelectedLevels() {
if (!this.selectedLevels || this.selectedLevels.length === 0) return false;
// Prüfe ob alle ausgewählten Level bezahlbar sind (kumulativ)
return this.selectedLevels.every(levelNum => {
const level = this.availableLevels.find(l => l.targetLevel === levelNum);
return level && level.canAfford;
}) && this.totalCost <= (this.character.erfahrungsschatz?.ep || 0) &&
this.totalGoldCost <= (this.character.vermoegen?.goldstücke || 0) &&
this.totalPPCost <= (this.skill?.pp || 0);
},
totalCost() {
return this.selectedLevels.reduce((sum, levelNum) => {
const level = this.availableLevels.find(l => l.targetLevel === levelNum);
return sum + (level ? level.epCost : 0);
}, 0);
},
totalGoldCost() {
return this.selectedLevels.reduce((sum, levelNum) => {
const level = this.availableLevels.find(l => l.targetLevel === levelNum);
return sum + (level ? level.goldCost : 0);
}, 0);
},
totalPPCost() {
return this.selectedLevels.reduce((sum, levelNum) => {
const level = this.availableLevels.find(l => l.targetLevel === levelNum);
if (!level) return sum;
// PP werden nur bei bestimmten Belohnungstypen tatsächlich verwendet
switch (this.selectedRewardType) {
case 'pp':
return sum + (level.ppCost || 0);
case 'mixed':
return sum + (level.ppUsed || 0);
default:
return sum; // Bei anderen Belohnungstypen werden keine PP verwendet
}
}, 0);
},
finalTargetLevel() {
if (this.selectedLevels.length === 0) return this.skill?.fertigkeitswert || 0;
return Math.max(...this.selectedLevels);
},
selectedCost() {
return this.totalCost;
},
selectedGoldCost() {
return this.totalGoldCost;
},
selectedPPCost() {
return this.totalPPCost;
},
remainingEP() {
const current = this.character.erfahrungsschatz?.ep || 0;
return Math.max(0, current - this.totalCost);
},
remainingGold() {
const current = this.character.vermoegen?.goldstücke || 0;
return Math.max(0, current - this.totalGoldCost);
},
remainingPP() {
const current = this.skill?.pp || 0;
return Math.max(0, current - this.totalPPCost);
}
},
watch: {
skill: {
handler(newSkill) {
if (newSkill) {
this.loadRewardTypes();
// loadLearningCosts wird durch selectedRewardType watcher ausgelöst
}
},
immediate: true
},
learningType: {
handler() {
if (this.skill) {
this.loadRewardTypes();
// loadLearningCosts wird durch selectedRewardType watcher ausgelöst
}
},
immediate: true
},
selectedRewardType() {
// Nur hier die Lernkosten laden, wenn Belohnungsart geändert wird
if (this.selectedRewardType && this.skill) {
this.loadLearningCosts();
}
this.selectedLevels = []; // Reset selection when reward type changes
},
isVisible(newValue) {
if (newValue) {
this.resetDialog();
}
}
},
created() {
this.$api = API;
},
methods: {
closeDialog() {
this.$emit('close');
},
resetDialog() {
this.selectedRewardType = '';
this.selectedLevels = [];
this.ppUsed = 0;
this.goldUsed = 0;
this.notes = '';
this.availableLevels = [];
this.availableRewardTypes = [];
if (this.skill) {
this.loadRewardTypes();
// loadLearningCosts wird automatisch durch selectedRewardType watcher ausgelöst
}
},
async loadRewardTypes() {
if (!this.skill) return;
// Prüfe ob Token vorhanden ist
const token = localStorage.getItem('token');
if (!token) {
console.error('No authentication token available - cannot load reward types');
this.availableRewardTypes = [{
value: 'error',
label: 'Anmeldung erforderlich'
}];
return;
}
this.isLoadingRewardTypes = true;
try {
console.log('Loading reward types for:', {
character_id: this.character.id,
learning_type: this.learningType,
skill_name: this.skill.name,
current_level: this.skill.fertigkeitswert || 0,
skill_type: this.skill.type || 'skill',
has_token: !!token
});
// API-Endpunkt für verfügbare Belohnungsarten
const response = await API.get(`/api/characters/${this.character.id}/reward-types`, {
params: {
learning_type: this.learningType,
skill_name: this.skill.name,
current_level: this.skill.fertigkeitswert || 0,
skill_type: this.skill.type || 'skill' // 'skill', 'weapon', 'spell'
}
});
console.log('API Response:', response.data);
console.log('Reward types from API:', response.data.reward_types);
this.availableRewardTypes = response.data.reward_types || [];
// Setze den ersten verfügbaren Belohnungstyp als Standard
if (this.availableRewardTypes.length > 0 && !this.selectedRewardType) {
this.selectedRewardType = this.availableRewardTypes[0].value;
console.log('Set default reward type to:', this.selectedRewardType);
// loadLearningCosts wird automatisch durch selectedRewardType watcher ausgelöst
} else if (this.availableRewardTypes.length === 0) {
console.error('No reward types received from API - cannot proceed without proper reward types');
// Zeige Fehlermeldung statt Fallback zu verwenden
this.availableRewardTypes = [{
value: 'error',
label: 'Fehler beim Laden der Belohnungsarten'
}];
}
} catch (error) {
console.error('Fehler beim Laden der Belohnungsarten:', error);
console.error('Error details:', {
status: error.response?.status,
statusText: error.response?.statusText,
data: error.response?.data,
url: error.config?.url,
headers: error.config?.headers
});
// Spezielle Behandlung für Auth-Fehler
if (error.response?.status === 401) {
console.error('Authentication failed - token may be invalid or expired');
console.log('Current token:', token ? 'Present' : 'Missing');
// Optional: Token entfernen wenn ungültig
// localStorage.removeItem('token');
// Event emittieren um Parent über Auth-Problem zu informieren
this.$emit('auth-error', 'Authentication required for reward types');
} else if (error.response?.status === 404) {
console.warn('Reward types endpoint not found, using fallback');
} else if (error.response?.status === 403) {
console.error('Access forbidden - insufficient permissions');
}
// Bei Fehlern klare Fehlermeldung statt Fallback
this.availableRewardTypes = [{
value: 'error',
label: 'Fehler beim Laden der Belohnungsarten'
}];
console.error('Could not load reward types from API, showing error state');
} finally {
this.isLoadingRewardTypes = false;
}
},
calculateAvailableLevels() {
if (!this.skill) return;
// Diese Methode ist jetzt redundant, da loadLearningCosts()
// automatisch durch den selectedRewardType watcher aufgerufen wird
console.warn('calculateAvailableLevels() ist veraltet - verwende selectedRewardType watcher');
},
async loadLearningCosts() {
if (!this.skill || !this.selectedRewardType) return;
this.isLoading = true;
try {
const token = localStorage.getItem('token');
if (!token) {
console.warn('No authentication token available for cost calculation');
this.generateFallbackLevels();
return;
}
console.log('Loading learning costs for:', {
character_id: this.character.id,
skill_name: this.skill.name,
skill_type: this.skill.type || 'skill',
learning_type: this.learningType,
current_level: this.skill.fertigkeitswert || 0,
reward_type: this.selectedRewardType
});
// Verwende den neuen /lerncost Endpunkt mit gsmaster.LernCostRequest Struktur
const requestData = {
char_id: parseInt(this.character.id),
name: this.skill.name,
current_level: this.skill.fertigkeitswert || 0,
type: this.skill.type || 'skill',
action: this.learningType === 'learn' ? 'learn' : 'improve',
target_level: 0, // Wird vom Backend automatisch bis Level 18 berechnet
use_pp: this.ppUsed || 0,
use_gold: this.goldUsed || 0,
reward: this.selectedRewardType
};
const response = await API.post(`/api/characters/lerncost-new`, requestData);
console.log('Learning costs API response:', response.data);
if (response.data && Array.isArray(response.data) && response.data.length > 0) {
// Konvertiere gsmaster.SkillCostResultNew Array zu unserem internen Format
const availableEP = this.character.erfahrungsschatz?.ep || 0;
const availableGold = this.character.vermoegen?.goldstücke || 0;
const availablePP = this.skill?.pp || 0;
this.availableLevels = response.data.map(cost => {
// Backend liefert bereits die korrekten Kosten basierend auf dem Belohnungstyp
let canAfford = false;
switch (this.selectedRewardType) {
case 'noGold':
case 'halveepnoGold':
canAfford = availableEP >= cost.ep;
break;
case 'pp':
canAfford = availablePP >= cost.le;
break;
case 'mixed':
canAfford = availableEP >= cost.ep && availablePP >= (cost.pp_used || 0);
break;
case 'default':
default:
canAfford = availableEP >= cost.ep && availableGold >= cost.gold_cost;
break;
}
return {
startLevel: Math.max(cost.target_level - 1, 0),
targetLevel: cost.target_level,
epCost: cost.ep,
goldCost: cost.gold_cost,
ppCost: cost.le,
ppUsed: cost.pp_used || 0,
canAfford: canAfford
};
});
console.log('Processed level costs for reward type', this.selectedRewardType, ':', this.availableLevels);
} else {
console.warn('No level costs returned from API, using fallback');
this.generateFallbackLevels();
}
} catch (error) {
console.error('Fehler beim Laden der Lernkosten:', error);
console.error('Error details:', {
status: error.response?.status,
statusText: error.response?.statusText,
data: error.response?.data
});
if (error.response?.status === 401) {
console.error('Authentication failed for learning costs');
this.$emit('auth-error', 'Authentication required for learning costs');
}
// Fallback auf berechnete Kosten
this.generateFallbackLevels();
} finally {
this.isLoading = false;
}
},
generateFallbackLevels() {
// Einfache Fallback-Methode für Kostenberechnung (nur für Notfälle)
const currentLevel = this.skill.fertigkeitswert || 0;
const maxLevel = 20;
const availableEP = this.character.erfahrungsschatz?.ep || 0;
const availableGold = this.character.vermoegen?.goldstücke || 0;
const availablePP = this.skill?.pp || 0;
this.availableLevels = [];
for (let targetLevel = currentLevel + 1; targetLevel <= Math.min(currentLevel + 5, maxLevel); targetLevel++) {
const levelDiff = targetLevel - currentLevel;
// Sehr einfache Basis-Kosten (nur als Fallback)
const epCost = levelDiff * 100;
const goldCost = levelDiff * 50;
const ppCost = levelDiff * 20;
// Verfügbarkeit basierend auf Belohnungstyp
let canAfford = false;
switch (this.selectedRewardType) {
case 'noGold':
case 'halveepnoGold':
canAfford = availableEP >= epCost;
break;
case 'pp':
canAfford = availablePP >= ppCost;
break;
case 'mixed':
canAfford = availableEP >= epCost && availablePP >= Math.min(levelDiff * 5, availablePP);
break;
case 'default':
default:
canAfford = availableEP >= epCost && availableGold >= goldCost;
break;
}
this.availableLevels.push({
startLevel: targetLevel - 1,
targetLevel,
epCost,
goldCost,
ppCost,
ppUsed: 0,
canAfford
});
}
},
selectLevel(level) {
if (!level.canAfford) return;
const targetLevel = level.targetLevel;
const currentLevel = this.skill?.fertigkeitswert || 0;
// Toggle-Logik für Multi-Level-Auswahl
const isSelected = this.selectedLevels.includes(targetLevel);
if (isSelected) {
// Entferne das Level und alle höheren Level
this.selectedLevels = this.selectedLevels.filter(l => l < targetLevel);
} else {
// Füge das Level hinzu und alle Level zwischen dem aktuellen Level und diesem Level
const levelsToAdd = [];
// Bestimme den niedrigsten bereits ausgewählten Level oder das aktuelle Level
const minSelectedLevel = this.selectedLevels.length > 0
? Math.min(...this.selectedLevels)
: currentLevel + 1;
// Füge alle Level vom niedrigsten bis zum angeklickten Level hinzu
for (let i = Math.min(minSelectedLevel, targetLevel); i <= targetLevel; i++) {
if (i > currentLevel && !this.selectedLevels.includes(i)) {
// Prüfe ob das Level verfügbar und bezahlbar ist
const levelData = this.availableLevels.find(l => l.targetLevel === i);
if (levelData && levelData.canAfford) {
levelsToAdd.push(i);
}
}
}
// Aktualisiere die Auswahl
this.selectedLevels = [...new Set([...this.selectedLevels, ...levelsToAdd])].sort((a, b) => a - b);
}
console.log('Selected levels:', this.selectedLevels);
},
isInSelectedSequence(targetLevel) {
// Prüft ob ein Level Teil der ausgewählten Sequenz ist (visueller Hinweis)
if (this.selectedLevels.length === 0) return false;
const minSelected = Math.min(...this.selectedLevels);
const maxSelected = Math.max(...this.selectedLevels);
return targetLevel >= minSelected && targetLevel <= maxSelected;
},
updatePPUsage() {
// Stelle sicher, dass PP-Verwendung die verfügbaren PP nicht überschreitet
const maxPP = this.skill?.pp || 0;
if (this.ppUsed > maxPP) {
this.ppUsed = maxPP;
}
if (this.ppUsed < 0) {
this.ppUsed = 0;
}
// Lernkosten immer neu laden, da PP-Verwendung die Kosten beeinflussen kann
if (this.selectedRewardType && this.skill) {
this.loadLearningCosts();
}
},
updateGoldUsage() {
// Stelle sicher, dass Gold-Verwendung die verfügbaren GS nicht überschreitet
const maxGold = this.character.vermoegen?.goldstücke || 0;
if (this.goldUsed > maxGold) {
this.goldUsed = maxGold;
}
if (this.goldUsed < 0) {
this.goldUsed = 0;
}
// Lernkosten immer neu laden, da Gold-Verwendung die Kosten beeinflussen kann
if (this.selectedRewardType && this.skill) {
this.loadLearningCosts();
}
},
updateMixedCosts() {
// Diese Methode ist jetzt redundant, da updatePPUsage() alles übernimmt
this.updatePPUsage();
},
async executeDetailedLearning() {
if (!this.skill || !this.selectedLevels.length) {
alert('Bitte wählen Sie mindestens eine Zielstufe aus.');
return;
}
if (!this.selectedRewardType) {
alert('Bitte wählen Sie eine Belohnungsart aus.');
return;
}
if (!this.canAffordSelectedLevels) {
alert('Sie haben nicht genügend Ressourcen für diese Verbesserung(en).');
return;
}
this.isLoading = true;
try {
// Für Multi-Level-Learning senden wir das höchste Level als Ziel
const finalLevel = Math.max(...this.selectedLevels);
const requestData = {
char_id: this.character.id,
name: this.skill.name,
current_level: this.skill.fertigkeitswert,
target_level: finalLevel, // Höchstes ausgewähltes Level
type: this.learningType === 'spell' ? 'spell' : 'skill',
action: this.learningType === 'learn' ? 'learn' : 'improve',
reward: this.selectedRewardType,
use_pp: this.ppUsed || 0,
use_gold: this.goldUsed || 0,
levels_to_learn: this.selectedLevels, // Alle ausgewählten Level
notes: this.notes || `${this.learningType === 'spell' ? 'Zauber' : 'Fertigkeit'} ${this.skill.name} von ${this.skill.fertigkeitswert} auf ${finalLevel} ${this.learningType === 'learn' ? 'gelernt' : 'verbessert'} (${this.selectedLevels.length} Level)`
};
// Wähle den richtigen API-Endpunkt basierend auf Lerntyp
let endpoint;
switch (this.learningType) {
case 'learn':
endpoint = `/api/characters/${this.character.id}/learn-skill`;
break;
case 'spell':
endpoint = `/api/characters/${this.character.id}/improve-spell`;
break;
case 'improve':
default:
endpoint = `/api/characters/improve-skill`;
break;
}
const response = await API.post(endpoint, requestData);
console.log(`${this.learningType} erfolgreich ausgeführt:`, response.data);
alert(`${this.learningType === 'spell' ? 'Zauber' : 'Fertigkeit'} "${this.skill.name}" erfolgreich ${this.learningType === 'learn' ? 'gelernt' : 'auf Stufe ' + finalLevel + ' verbessert'} (${this.selectedLevels.length} Level)!`);
this.$emit('skill-updated');
this.closeDialog();
} catch (error) {
console.error(`Fehler beim ${this.learningType}:`, error);
alert(`Fehler beim ${this.learningType === 'learn' ? 'Lernen' : 'Verbessern'}: ` + (error.response?.data?.error || error.message));
} finally {
this.isLoading = false;
}
}
}
};
</script>
@@ -1,744 +0,0 @@
<template>
<div v-if="isVisible" class="modal-overlay" @click.self="closeDialog">
<div class="modal-content modal-fullscreen skill-learn-dialog">
<div class="dialog-header">
<h3>Neue Fertigkeit lernen</h3>
<button @click="closeDialog" class="btn-close">&times;</button>
</div>
<!-- Ressourcen-Anzeige -->
<div class="resources-section">
<h4>Verfügbare Ressourcen</h4>
<div class="current-resources">
<div class="resource-display-card">
<span class="resource-icon"></span>
<div class="resource-info">
<div class="resource-label">Erfahrungspunkte</div>
<div class="resource-amount">{{ character.erfahrungsschatz?.ep || 0 }} EP</div>
<div class="resource-remaining" v-if="totalSelectedEP > 0">
<small>
Verwendet: {{ totalSelectedEP }} EP |
Verbleibend: {{ remainingEP }} EP
</small>
</div>
<div class="resource-remaining" v-if="totalSelectedEP > 0">
<small :class="{ 'text-warning': remainingEP < 50, 'text-danger': remainingEP <= 0 }">
Nach Lernen: {{ remainingEP }} EP
</small>
</div>
</div>
</div>
<div class="resource-display-card">
<span class="resource-icon">💰</span>
<div class="resource-info">
<div class="resource-label">Gold</div>
<div class="resource-amount">{{ character.vermoegen?.goldstücke || 0 }} GS</div>
<div class="resource-remaining" v-if="totalSelectedGold > 0 && rewardType === 'default'">
<small>
Verwendet: {{ totalSelectedGold }} GS |
Verbleibend: {{ remainingGold }} GS
</small>
</div>
<div class="resource-remaining" v-if="totalSelectedGold > 0 && rewardType === 'default'">
<small :class="{ 'text-warning': remainingGold < 20, 'text-danger': remainingGold <= 0 }">
Nach Lernen: {{ remainingGold }} GS
</small>
</div>
<div class="resource-remaining" v-if="rewardType === 'noGold' && totalSelectedEP > 0">
<small class="text-info">
Kein Gold benötigt (Belohnungslernen)
</small>
</div>
</div>
</div>
</div>
<!-- Lernmethode direkt unter den Ressourcen -->
<div class="reward-method-section">
<label for="rewardType">Lernen als Belohnung:</label>
<select id="rewardType" v-model="rewardType" class="form-select">
<option value="default">Standard (EP + Gold)</option>
<option value="noGold">Nur EP (kein Gold)</option>
</select>
<small class="form-hint">Wählen Sie die Art des Lernens</small>
</div>
</div>
<!-- Formular -->
<div class="form-section">
<!-- Fertigkeiten-Auswahl mit Drag & Drop -->
<div class="skills-selection-container">
<div class="skills-available">
<h4>Verfügbare Fertigkeiten</h4>
<!-- Kategorie-Filter -->
<div class="category-filters">
<button
@click="setCategoryFilter(null)"
class="category-filter-btn"
:class="{ 'active': selectedCategoryFilter === null }"
title="Alle Kategorien anzeigen"
>
Alle
</button>
<button
v-for="category in availableCategories"
:key="category.key || category.name"
@click="setCategoryFilter(category.name)"
class="category-filter-btn"
:class="{ 'active': selectedCategoryFilter === category.name }"
:title="category.description"
>
{{ category.name }}
</button>
</div>
<!-- Sortier- und Suchbereich -->
<div class="sort-and-search-controls">
<div class="sort-controls">
<span class="sort-label">Sortieren nach:</span>
<button
@click="setSortBy('name')"
class="sort-btn"
:class="{ 'active': sortBy === 'name' }"
title="Nach Name sortieren"
>
Name
<span v-if="sortBy === 'name'" class="sort-icon">
{{ sortOrder === 'asc' ? '' : '' }}
</span>
</button>
<button
@click="setSortBy('epCost')"
class="sort-btn"
:class="{ 'active': sortBy === 'epCost' }"
title="Nach EP-Kosten sortieren"
>
EP-Kosten
<span v-if="sortBy === 'epCost'" class="sort-icon">
{{ sortOrder === 'asc' ? '' : '' }}
</span>
</button>
</div>
<div class="skills-search">
<input
v-model="skillSearchFilter"
type="text"
placeholder="Fertigkeiten filtern..."
class="form-input search-input"
/>
</div>
</div>
<div class="skills-list" v-if="availableSkillsByCategory">
<div
v-for="skill in sortedFilteredSkills"
:key="skill.name"
class="skill-item"
:class="{ 'skill-affordable': skill.canAfford }"
draggable="true"
@dragstart="onDragStart($event, skill)"
@click="selectSkill(skill)"
>
<div class="skill-info">
<div class="skill-main-line">
<span class="skill-name">{{ skill.name }}</span>
<span class="skill-category">({{ skill.category }})</span>
<span class="skill-costs">
<span v-if="rewardType === 'default'" class="cost-ep">{{ skill.epCost }} EP</span>
<span v-if="rewardType === 'default'" class="cost-gold">{{ skill.goldCost }} GS</span>
<span v-if="rewardType === 'noGold'" class="cost-ep">{{ skill.epCost }} EP</span>
</span>
</div>
</div>
<div class="skill-actions">
<button
@click.stop="selectSkill(skill)"
class="btn-select"
:disabled="!skill.canAfford"
>
</button>
</div>
</div>
</div>
<div v-if="isLoadingSkills" class="loading-skills">
<span class="loading-spinner"></span> Lade Fertigkeiten...
</div>
</div>
<div class="skills-selected">
<h4>Zu lernende Fertigkeiten</h4>
<div
class="skills-drop-zone"
:class="{ 'drag-over': isDragOver }"
@dragover.prevent="isDragOver = true"
@dragleave.prevent="isDragOver = false"
@drop.prevent="onDrop"
>
<div v-if="selectedSkills.length === 0" class="drop-zone-placeholder">
<div class="placeholder-icon">📚</div>
<div class="placeholder-text">
Ziehen Sie Fertigkeiten hierher oder klicken Sie auf um sie auszuwählen
</div>
</div>
<div v-else class="selected-skills-list">
<div
v-for="(skill, index) in selectedSkills"
:key="skill.name + index"
class="selected-skill-item"
>
<div class="selected-skill-info">
<div class="selected-skill-name">{{ skill.name }}</div>
<div class="selected-skill-costs">
<span v-if="rewardType === 'default'" class="cost-ep">{{ skill.epCost }} EP</span>
<span v-if="rewardType === 'default'" class="cost-gold">{{ skill.goldCost }} GS</span>
<span v-if="rewardType === 'noGold'" class="cost-ep">{{ skill.epCost }} EP</span>
</div>
</div>
<button
@click="removeSelectedSkill(index)"
class="btn-remove"
title="Entfernen"
>
×
</button>
</div>
</div>
</div>
<!-- Gesamtkosten -->
<div v-if="selectedSkills.length > 0" class="total-costs">
<div class="total-costs-header">Gesamtkosten:</div>
<div class="total-costs-breakdown">
<span v-if="rewardType === 'default'" class="total-ep">{{ totalSelectedEP }} EP</span>
<span v-if="rewardType === 'default'" class="total-gold">{{ totalSelectedGold }} GS</span>
<span v-if="rewardType === 'noGold'" class="total-ep">{{ totalSelectedEP }} EP</span>
</div>
<div class="affordability-check">
<span
:class="{
'text-success': canAffordSelected,
'text-danger': !canAffordSelected
}"
>
{{ canAffordSelected ? '✓ Kann gelernt werden' : '✗ Nicht genügend Ressourcen' }}
</span>
</div>
</div>
</div>
</div>
<!-- Einfache Eingabe als Fallback -->
<div class="simple-input-section" v-if="!availableSkillsByCategory">
<div class="form-group">
<label for="skillName">Fertigkeitsname:</label>
<input
id="skillName"
v-model="skillName"
type="text"
placeholder="Name der neuen Fertigkeit eingeben..."
class="form-input"
@keyup.enter="learnSkill"
/>
</div>
</div>
<div class="form-group">
<label for="notes">Notizen (optional):</label>
<textarea
id="notes"
v-model="notes"
placeholder="Zusätzliche Notizen zum Lernen der Fertigkeit..."
class="form-textarea"
rows="3"
></textarea>
</div>
</div>
<!-- Kosten-Vorschau (falls implementiert) -->
<div v-if="estimatedCosts" class="costs-preview">
<h4>Geschätzte Kosten</h4>
<div class="cost-breakdown">
<div class="cost-item">
<span class="cost-label">EP-Kosten:</span>
<span class="cost-value">{{ estimatedCosts.ep || 0 }} EP</span>
</div>
<div v-if="rewardType === 'default'" class="cost-item">
<span class="cost-label">Gold-Kosten:</span>
<span class="cost-value">{{ estimatedCosts.gold || 0 }} GS</span>
</div>
</div>
</div>
<!-- Aktionen -->
<div class="modal-actions">
<div class="action-info">
<span v-if="selectedSkills.length > 0" class="selection-count">
{{ selectedSkills.length }} Fertigkeit{{ selectedSkills.length !== 1 ? 'en' : '' }} ausgewählt
</span>
</div>
<div class="action-buttons">
<button
@click="learnSelectedSkills"
class="btn-confirm"
:disabled="selectedSkills.length === 0 || !canAffordSelected || isLoading"
>
<span v-if="isLoading" class="loading-spinner"></span>
{{ isLoading ? 'Lerne...' : (selectedSkills.length > 1 ? 'Fertigkeiten lernen' : 'Fertigkeit lernen') }}
</button>
<button @click="closeDialog" class="btn-cancel" :disabled="isLoading">
Abbrechen
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import API from '@/utils/api'
export default {
name: 'SkillLearnDialog',
props: {
character: {
type: Object,
required: true
},
isVisible: {
type: Boolean,
default: false
}
},
data() {
return {
skillName: '',
rewardType: 'default',
notes: '',
isLoading: false,
estimatedCosts: null,
// Neue Eigenschaften für Fertigkeiten-Auswahl
availableSkillsByCategory: null,
selectedSkills: [],
skillSearchFilter: '',
isDragOver: false,
isLoadingSkills: false,
// Kategorie-Filter
availableCategories: [],
selectedCategoryFilter: null,
// Sortierung
sortBy: 'name', // 'name', 'epCost'
sortOrder: 'asc' // 'asc', 'desc'
}
},
computed: {
filteredSkillsByCategory() {
let filtered = this.availableSkillsByCategory
if (!filtered) {
return filtered
}
// Kategorie-Filter anwenden
if (this.selectedCategoryFilter) {
filtered = {
[this.selectedCategoryFilter]: filtered[this.selectedCategoryFilter] || []
}
}
// Text-Suchfilter anwenden
if (this.skillSearchFilter) {
const result = {}
const searchTerm = this.skillSearchFilter.toLowerCase()
Object.keys(filtered).forEach(category => {
const filteredSkills = filtered[category].filter(skill =>
skill.name.toLowerCase().includes(searchTerm)
)
if (filteredSkills.length > 0) {
result[category] = filteredSkills
}
})
return result
}
return filtered
},
sortedFilteredSkills() {
if (!this.availableSkillsByCategory) {
return []
}
let allSkills = []
// Sammle alle Fertigkeiten aus allen Kategorien
Object.keys(this.availableSkillsByCategory).forEach(category => {
this.availableSkillsByCategory[category].forEach(skill => {
allSkills.push({
...skill,
category: category,
canAfford: this.canAffordSkill(skill) // Berechne canAfford im Frontend
})
})
})
// Entferne bereits ausgewählte Fertigkeiten
const selectedSkillNames = this.selectedSkills.map(s => s.name)
allSkills = allSkills.filter(skill =>
!selectedSkillNames.includes(skill.name)
)
// Anwenden der Filter
if (this.selectedCategoryFilter) {
allSkills = allSkills.filter(skill => skill.category === this.selectedCategoryFilter)
}
if (this.skillSearchFilter) {
const searchTerm = this.skillSearchFilter.toLowerCase()
allSkills = allSkills.filter(skill =>
skill.name.toLowerCase().includes(searchTerm)
)
}
// Sortiere nach dem gewählten Kriterium
if (this.sortBy === 'name') {
allSkills.sort((a, b) => {
const comparison = a.name.localeCompare(b.name)
return this.sortOrder === 'asc' ? comparison : -comparison
})
} else if (this.sortBy === 'epCost') {
allSkills.sort((a, b) => {
const comparison = (a.epCost || 0) - (b.epCost || 0)
return this.sortOrder === 'asc' ? comparison : -comparison
})
}
return allSkills
},
totalSelectedEP() {
return this.selectedSkills.reduce((total, skill) => total + (skill.epCost || 0), 0)
},
totalSelectedGold() {
return this.selectedSkills.reduce((total, skill) => total + (skill.goldCost || 0), 0)
},
canAffordSelected() {
const availableEP = this.character.erfahrungsschatz?.ep || 0
const availableGold = this.character.vermoegen?.goldstücke || 0
if (this.rewardType === 'default') {
return availableEP >= this.totalSelectedEP && availableGold >= this.totalSelectedGold
} else if (this.rewardType === 'noGold') {
return availableEP >= this.totalSelectedEP
}
return false
},
remainingEP() {
const current = this.character.erfahrungsschatz?.ep || 0
return Math.max(0, current - this.totalSelectedEP)
},
remainingGold() {
const current = this.character.vermoegen?.goldstücke || 0
return Math.max(0, current - this.totalSelectedGold)
}
},
watch: {
isVisible(newVal) {
if (newVal) {
this.loadAvailableSkills()
} else {
this.resetForm()
}
},
rewardType() {
// Lade Fertigkeiten neu bei Änderung der Lernmethode
if (this.isVisible) {
this.loadAvailableSkills()
}
}
},
mounted() {
this.$api = API
if (this.isVisible) {
this.loadAvailableSkills()
}
},
methods: {
closeDialog() {
this.$emit('close')
},
canAffordSkill(skill) {
const availableEP = this.character.erfahrungsschatz?.ep || 0
const availableGold = this.character.vermoegen?.goldstücke || 0
if (this.rewardType === 'default' || this.rewardType === '') {
return availableEP >= (skill.epCost || 0) && availableGold >= (skill.goldCost || 0)
} else if (this.rewardType === 'noGold') {
return availableEP >= (skill.epCost || 0)
}
return false
},
resetForm() {
this.skillName = ''
this.rewardType = 'default'
this.notes = ''
this.isLoading = false
this.estimatedCosts = null
this.selectedSkills = []
this.skillSearchFilter = ''
this.isDragOver = false
this.selectedCategoryFilter = null
this.availableSkillsByCategory = null
this.sortBy = 'name'
this.sortOrder = 'asc'
},
async loadAvailableSkills() {
this.isLoadingSkills = true
try {
// Lade verfügbare Kategorien
await this.loadAvailableCategories()
// Lade alle verfügbaren Fertigkeiten mit Kosten (bereits ohne gelernte gefiltert)
const requestData = {
char_id: this.character.id,
name: '', // Wird für jede Fertigkeit einzeln gesetzt
current_level: 0,
target_level: 1,
type: 'skill',
action: 'learn',
use_pp: 0,
use_gold: 0,
reward: this.rewardType || 'default'
}
const response = await this.$api.post('/api/characters/available-skills-new', requestData)
if (response.data && response.data.skills_by_category) {
this.availableSkillsByCategory = response.data.skills_by_category
console.log('Loaded skills by category:', this.availableSkillsByCategory)
} else {
// Fallback: Generiere Beispiel-Fertigkeiten
this.generateSampleSkills()
}
} catch (error) {
console.error('Fehler beim Laden der Fertigkeiten:', error)
// Fallback: Generiere Beispiel-Fertigkeiten
this.generateSampleSkills()
} finally {
this.isLoadingSkills = false
}
},
async loadAvailableCategories() {
try {
const response = await this.$api.get('/api/characters/skill-categories')
if (response.data && response.data.skill_categories) {
// Extrahiere die Namen und Beschreibungen der Kategorien
const categories = response.data.skill_categories
this.availableCategories = Object.keys(categories).map(key => ({
name: categories[key].name,
description: categories[key].description,
key: key
}))
console.log('Loaded categories:', this.availableCategories)
} else {
// Fallback: Standard-Kategorien
this.availableCategories = [
{ name: 'Körperliche Fertigkeiten', description: 'Körperliche Fertigkeiten', key: 'körper' },
{ name: 'Geistige Fertigkeiten', description: 'Geistige Fertigkeiten', key: 'geist' },
{ name: 'Handwerkliche Fertigkeiten', description: 'Handwerkliche Fertigkeiten', key: 'handwerk' },
{ name: 'Magische Fertigkeiten', description: 'Magische Fertigkeiten', key: 'magie' },
{ name: 'Soziale Fertigkeiten', description: 'Soziale Fertigkeiten', key: 'sozial' }
]
}
} catch (error) {
console.error('Fehler beim Laden der Kategorien:', error)
// Fallback: Standard-Kategorien
this.availableCategories = [
{ name: 'Körperliche Fertigkeiten', description: 'Körperliche Fertigkeiten', key: 'körper' },
{ name: 'Geistige Fertigkeiten', description: 'Geistige Fertigkeiten', key: 'geist' },
{ name: 'Handwerkliche Fertigkeiten', description: 'Handwerkliche Fertigkeiten', key: 'handwerk' },
{ name: 'Magische Fertigkeiten', description: 'Magische Fertigkeiten', key: 'magie' },
{ name: 'Soziale Fertigkeiten', description: 'Soziale Fertigkeiten', key: 'sozial' }
]
}
},
generateSampleSkills() {
// Fallback für Testzwecke
const availableEP = this.character.erfahrungsschatz?.ep || 0
const availableGold = this.character.vermoegen?.goldstücke || 0
this.availableSkillsByCategory = {
'Körperliche Fertigkeiten': [
{ name: 'Klettern', epCost: 100, goldCost: 50, canAfford: availableEP >= 100 && availableGold >= 50 },
{ name: 'Schwimmen', epCost: 80, goldCost: 40, canAfford: availableEP >= 80 && availableGold >= 40 },
{ name: 'Springen', epCost: 60, goldCost: 30, canAfford: availableEP >= 60 && availableGold >= 30 }
],
'Geistige Fertigkeiten': [
{ name: 'Erste Hilfe', epCost: 120, goldCost: 60, canAfford: availableEP >= 120 && availableGold >= 60 },
{ name: 'Naturkunde', epCost: 150, goldCost: 75, canAfford: availableEP >= 150 && availableGold >= 75 },
{ name: 'Menschenkenntnis', epCost: 130, goldCost: 65, canAfford: availableEP >= 130 && availableGold >= 65 }
],
'Handwerkliche Fertigkeiten': [
{ name: 'Bogenbau', epCost: 200, goldCost: 100, canAfford: availableEP >= 200 && availableGold >= 100 },
{ name: 'Schmieden', epCost: 250, goldCost: 125, canAfford: availableEP >= 250 && availableGold >= 125 }
]
}
// Setze verfügbare Kategorien aus den Beispieldaten
this.availableCategories = Object.keys(this.availableSkillsByCategory).map(key => ({
name: key,
description: key,
key: key.toLowerCase().replace(/\s+/g, '_')
}))
console.log('Generated sample skills:', this.availableSkillsByCategory)
},
setCategoryFilter(categoryName) {
this.selectedCategoryFilter = categoryName
console.log('Category filter set to:', categoryName)
},
setSortBy(sortBy) {
if (this.sortBy === sortBy) {
// Gleicher Sortiertyp - Reihenfolge umkehren
this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc'
} else {
// Neuer Sortiertyp - standardmäßig aufsteigend
this.sortBy = sortBy
this.sortOrder = 'asc'
}
console.log('Sort set to:', this.sortBy, this.sortOrder)
},
onDragStart(event, skill) {
event.dataTransfer.setData('application/json', JSON.stringify(skill))
event.dataTransfer.effectAllowed = 'copy'
},
onDrop(event) {
this.isDragOver = false
try {
const skillData = JSON.parse(event.dataTransfer.getData('application/json'))
this.selectSkill(skillData)
} catch (error) {
console.error('Fehler beim Drag & Drop:', error)
}
},
selectSkill(skill) {
if (!skill.canAfford) {
alert('Sie haben nicht genügend Ressourcen für diese Fertigkeit.')
return
}
// Prüfe ob Fertigkeit bereits ausgewählt (sollte eigentlich nicht passieren, da gefiltert)
const alreadySelected = this.selectedSkills.some(s => s.name === skill.name)
if (alreadySelected) {
alert('Diese Fertigkeit ist bereits ausgewählt.')
return
}
// Füge Fertigkeit zu den ausgewählten hinzu
this.selectedSkills.push({ ...skill })
console.log('Skill selected:', skill.name)
console.log('Currently selected skills:', this.selectedSkills.map(s => s.name))
},
removeSelectedSkill(index) {
const removedSkill = this.selectedSkills[index]
this.selectedSkills.splice(index, 1)
console.log('Skill removed from selection:', removedSkill.name)
console.log('Currently selected skills:', this.selectedSkills.map(s => s.name))
},
async learnSelectedSkills() {
if (this.selectedSkills.length === 0) {
alert('Bitte wählen Sie mindestens eine Fertigkeit aus.')
return
}
if (!this.canAffordSelected) {
alert('Sie haben nicht genügend Ressourcen für die ausgewählten Fertigkeiten.')
return
}
this.isLoading = true
try {
// Lerne alle ausgewählten Fertigkeiten
const learnPromises = this.selectedSkills.map(skill => {
const requestData = {
char_id: this.character.id,
name: skill.name,
current_level: 0,
target_level: 1,
type: 'skill',
action: 'learn',
reward: this.rewardType || 'default' // Immer das reward-Feld setzen
}
return this.$api.post(`/api/characters/${this.character.id}/learn-skill`, requestData)
})
const responses = await Promise.all(learnPromises)
console.log('Fertigkeiten erfolgreich gelernt:', responses.map(r => r.data))
const skillNames = this.selectedSkills.map(s => s.name).join(', ')
alert(`Fertigkeiten erfolgreich gelernt: ${skillNames}`)
this.closeDialog()
this.$emit('skill-learned', {
skills: this.selectedSkills,
responses: responses.map(r => r.data)
})
} catch (error) {
console.error('Fehler beim Lernen der Fertigkeiten:', error)
const errorMessage = error.response?.data?.error || error.message || 'Unbekannter Fehler'
alert('Fehler beim Lernen der Fertigkeiten: ' + errorMessage)
} finally {
this.isLoading = false
}
},
// Utility: Debounce function
debounce(func, wait) {
let timeout
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout)
func(...args)
}
clearTimeout(timeout)
timeout = setTimeout(later, wait)
}
}
}
}
</script>
<style scoped>
/* Component-specific styles - common styles are in main.css */
</style>
-809
View File
@@ -1,809 +0,0 @@
<template>
<div class="fullwidth-container">
<div class="cd-list">
<div class="tables-container">
<div class="table-wrapper-left">
<div class="header-section">
<span
v-if="isOwner"
class="help-icon"
:title="$t('characters.datasheet.editHelp')"
role="img"
:aria-label="$t('characters.datasheet.editHelp')"
>
?
</span>
<!-- Lernmodus Toggle Button -->
<div v-if="isOwner" class="learning-mode-controls">
<!-- Ressourcen-Anzeige (nur sichtbar wenn Lernmodus aktiv) -->
<div v-if="learningMode" class="resources-display">
<div class="resource-item">
<span class="resource-icon"></span>
<span class="resource-value">{{ character.erfahrungsschatz?.ep || 0 }} EP</span>
</div>
<div class="resource-item">
<span class="resource-icon">💰</span>
<span class="resource-value">{{ character.vermoegen?.goldstücke || 0 }} Gold</span>
</div>
</div>
<button
@click="toggleLearningMode"
class="btn-learning-mode"
:class="{ active: learningMode }"
:title="learningMode ? 'Lernmodus beenden' : 'Lernmodus aktivieren'"
>
<span class="icon">🎓</span>
</button>
<!-- Lernmodus-Buttons (nur sichtbar wenn Lernmodus aktiv) -->
<div v-if="learningMode" class="learning-actions">
<button
@click="showLearnNewDialog"
class="btn-learn-new"
title="Neue Fertigkeit lernen"
>
<span class="icon">📚</span>
</button>
<button
@click="openAddDialog"
class="btn-add"
title="Fertigkeit hinzufügen"
>
<span class="icon"></span>
</button>
</div>
</div>
</div>
<table class="cd-table">
<thead>
<tr>
<th class="cd-table-header" width="60%">{{ $t('skill.name') }}</th>
<th class="cd-table-header" width="35">{{ $t('skill.value') }}</th>
<th class="cd-table-header" width="35">{{ $t('skill.bonus') }}</th>
<th class="cd-table-header" width="35">{{ $t('skill.pp') }}</th>
<th class="cd-table-header" width="30%">{{ $t('skill.note') }}</th>
<th v-if="learningMode" class="cd-table-header" width="80">Aktionen</th>
</tr>
</thead>
<tbody>
<template v-for="skills,categorie in character.categorizedskills">
<tr>
<td :colspan="learningMode ? 6 : 5">{{ categorie || '-' }}</td>
</tr>
<template v-for="skill in skills">
<tr>
<td>
<span
v-if="!isEditingSkill(skill, 'name')"
@dblclick="isOwner ? startEditSkill(skill, 'name') : null"
:class="{ 'editable-prop': isOwner }"
>{{ skill.name || '-' }}</span>
<input
v-else
v-model="editValue"
@blur="saveEditSkill(skill, 'name')"
@keyup.enter="saveEditSkill(skill, 'name')"
@keyup.esc="cancelEditSkill"
ref="editInput"
class="prop-input"
type="text"
/>
</td>
<td>
<span
v-if="!isEditingSkill(skill, 'fertigkeitswert')"
@dblclick="isOwner ? startEditSkill(skill, 'fertigkeitswert') : null"
:class="{ 'editable-prop': isOwner }"
>{{ skill.fertigkeitswert || '-' }}</span>
<input
v-else
v-model="editValue"
@blur="saveEditSkill(skill, 'fertigkeitswert')"
@keyup.enter="saveEditSkill(skill, 'fertigkeitswert')"
@keyup.esc="cancelEditSkill"
ref="editInput"
class="prop-input"
type="number"
min="0"
max="20"
/>
</td>
<td>{{ skill.bonus || '0' }}</td>
<td class="pp-cell">
<div v-if="isOwner" class="pp-container">
<button
@click="decreasePP(skill)"
class="pp-btn pp-btn-minus"
:disabled="(skill.pp || 0) <= 0"
title="Praxispunkt entfernen"
>
</button>
<span class="pp-value">{{ skill.pp || '0' }}</span>
<button
@click="increasePP(skill)"
class="pp-btn pp-btn-plus"
title="Praxispunkt hinzufügen"
>
+
</button>
</div>
<span v-else>{{ skill.pp || '0' }}</span>
</td>
<td>
<span
v-if="!isEditingSkill(skill, 'bemerkung')"
@dblclick="isOwner ? startEditSkill(skill, 'bemerkung') : null"
:class="{ 'editable-prop': isOwner }"
>{{ skill.bemerkung || '-' }}</span>
<input
v-else
v-model="editValue"
@blur="saveEditSkill(skill, 'bemerkung')"
@keyup.enter="saveEditSkill(skill, 'bemerkung')"
@keyup.esc="cancelEditSkill"
ref="editInput"
class="prop-input"
type="text"
/>
</td>
<td v-if="learningMode" class="action-cell">
<button
@click="improveSkill(skill)"
class="btn-action btn-improve-small"
title="Fertigkeit verbessern"
>
</button>
</td>
</tr>
</template>
</template>
<tr>
<td class="cd-table-header" :colspan="learningMode ? 6 : 5">Waffenfertigkeiten</td>
</tr>
<template v-for="skill in character.waffenfertigkeiten">
<tr>
<td>
<span
v-if="!isEditingSkill(skill, 'name')"
@dblclick="isOwner ? startEditSkill(skill, 'name') : null"
:class="{ 'editable-prop': isOwner }"
>{{ skill.name || '-' }}</span>
<input
v-else
v-model="editValue"
@blur="saveEditSkill(skill, 'name')"
@keyup.enter="saveEditSkill(skill, 'name')"
@keyup.esc="cancelEditSkill"
ref="editInput"
class="prop-input"
type="text"
/>
</td>
<td>
<span
v-if="!isEditingSkill(skill, 'fertigkeitswert')"
@dblclick="isOwner ? startEditSkill(skill, 'fertigkeitswert') : null"
:class="{ 'editable-prop': isOwner }"
>{{ skill.fertigkeitswert || '-' }}</span>
<input
v-else
v-model="editValue"
@blur="saveEditSkill(skill, 'fertigkeitswert')"
@keyup.enter="saveEditSkill(skill, 'fertigkeitswert')"
@keyup.esc="cancelEditSkill"
ref="editInput"
class="prop-input"
type="number"
min="0"
max="20"
/>
</td>
<td>{{ skill.bonus || '0' }}</td>
<td class="pp-cell">
<div v-if="isOwner" class="pp-container">
<button
@click="decreaseWeaponPP(skill)"
class="pp-btn pp-btn-minus"
:disabled="(skill.pp || 0) <= 0"
title="Praxispunkt entfernen"
>
</button>
<span class="pp-value">{{ skill.pp || '0' }}</span>
<button
@click="increaseWeaponPP(skill)"
class="pp-btn pp-btn-plus"
title="Praxispunkt hinzufügen"
>
+
</button>
</div>
<span v-else>{{ skill.pp || '0' }}</span>
</td>
<td>
<span
v-if="!isEditingSkill(skill, 'bemerkung')"
@dblclick="isOwner ? startEditSkill(skill, 'bemerkung') : null"
:class="{ 'editable-prop': isOwner }"
>{{ skill.bemerkung || '-' }}</span>
<input
v-else
v-model="editValue"
@blur="saveEditSkill(skill, 'bemerkung')"
@keyup.enter="saveEditSkill(skill, 'bemerkung')"
@keyup.esc="cancelEditSkill"
ref="editInput"
class="prop-input"
type="text"
/>
</td>
<td v-if="learningMode" class="action-cell">
<button
@click="improveWeaponSkill(skill)"
class="btn-action btn-improve-small"
title="Waffenfertigkeit verbessern"
>
</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="table-wrapper-right">
<h2 style="line-height: 1.5; margin-top: 5px;"> Angeborene Fertigkeiten</h2>
<table class="cd-table">
<thead>
<tr>
<th class="cd-table-header" width="80%">{{ $t('skill.name') }}</th>
<th class="cd-table-header" width="35">{{ $t('skill.value') }}</th>
</tr>
</thead>
<tbody>
<template v-for="skill in character.InnateSkills">
<tr>
<td>{{ skill.name || '-' }}</td>
<td>{{ skill.fertigkeitswert || '-' }}</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div> <!--- end cd-list-->
<!-- Dialog für neue Fertigkeit lernen -->
<SkillLearnDialog
:character="character"
:isVisible="showLearnDialog"
@close="closeDialogs"
@skill-learned="handleSkillLearned"
/>
<!-- Dialog für Fertigkeit verbessern -->
<div v-if="showImproveSelectionDialog" class="modal-overlay" @click.self="closeDialogs">
<div class="modal-content">
<h3>Fertigkeit verbessern</h3>
<div class="form-group">
<label>Fertigkeit auswählen:</label>
<select v-model="selectedSkillToImprove">
<option value="">-- Fertigkeit wählen --</option>
<optgroup label="Fertigkeiten">
<template v-for="(skills, category) in character.categorizedskills" :key="category">
<option v-for="skill in skills"
:key="skill.name"
:value="skill">
{{ skill.name }} ({{ skill.fertigkeitswert }})
</option>
</template>
</optgroup>
<optgroup label="Waffenfertigkeiten">
<option v-for="skill in character.waffenfertigkeiten"
:key="skill.name"
:value="skill">
{{ skill.name }} ({{ skill.fertigkeitswert }})
</option>
</optgroup>
</select>
</div>
<div class="form-group">
<label>Praxispunkte verwenden:</label>
<input v-model.number="usePP" type="number" min="0" placeholder="Anzahl PP" />
</div>
<div class="form-group">
<label>Notizen (optional):</label>
<textarea v-model="improveNotes" placeholder="Zusätzliche Notizen..."></textarea>
</div>
<div class="modal-actions">
<button @click="improveSelectedSkill" class="btn-confirm" :disabled="!selectedSkillToImprove">Verbessern</button>
<button @click="closeDialogs" class="btn-cancel">Abbrechen</button>
</div>
</div>
</div>
<!-- Dialog für Fertigkeit hinzufügen -->
<div v-if="showAddDialog" class="modal-overlay" @click.self="closeDialogs">
<div class="modal-content">
<h3>Fertigkeit hinzufügen</h3>
<div class="form-group">
<label>Fertigkeitsname:</label>
<input v-model="addSkillName" type="text" placeholder="Name der Fertigkeit" />
</div>
<div class="form-group">
<label>Fertigkeitswert:</label>
<input v-model.number="addSkillValue" type="number" min="0" max="20" placeholder="Wert (0-20)" />
</div>
<div class="form-group">
<label>Notizen (optional):</label>
<textarea v-model="addNotes" placeholder="Zusätzliche Notizen..."></textarea>
</div>
<div class="modal-actions">
<button @click="addNewSkill" class="btn-confirm">Hinzufügen</button>
<button @click="closeDialogs" class="btn-cancel">Abbrechen</button>
</div>
</div>
</div>
<!-- Neue Dialog-Komponente für detailliertes Fertigkeiten-Lernen -->
<SkillImproveDialog
v-if="selectedSkillToLearn"
:character="character"
:skill="selectedSkillToLearn"
:isVisible="showDetailedLearnDialog"
:learningType="selectedLearningType"
@close="closeDialogs"
@skill-updated="handleSkillUpdated"
/>
</div> <!--- end character -datasheet-->
</template>
<style scoped>
/* Component-specific styles - common styles are in main.css */
/* Only component-specific overrides remain here */
.cd-table-header {
background-color: #1da766;
}
.resource-icon {
font-size: 16px;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
</style>
<script>
import API from '@/utils/api'
import SkillImproveDialog from './SkillImproveDialog.vue'
import SkillLearnDialog from './SkillLearnDialog.vue'
export default {
name: "SkillView",
components: {
SkillImproveDialog,
SkillLearnDialog
},
props: {
character: {
type: Object,
required: true
},
isOwner: {
type: Boolean,
default: false
}
},
data() {
return {
learningMode: false,
showLearnDialog: false,
showImproveSelectionDialog: false,
showAddDialog: false,
showDetailedLearnDialog: false,
// Formulardaten für Verbesserungs-Dialog (vereinfacht)
selectedSkillToImprove: null,
usePP: 0,
improveNotes: '',
addSkillName: '',
addSkillValue: 0,
addNotes: '',
// Detailliertes Lernen
selectedSkillToLearn: null,
selectedLearningType: 'improve', // 'improve', 'learn', 'spell'
// Inline editing
editingSkillId: null,
editingField: null,
editValue: '',
isLoading: false
};
},
created() {
this.$api = API;
},
methods: {
toggleLearningMode() {
this.learningMode = !this.learningMode;
if (!this.learningMode) {
this.closeDialogs();
}
},
showLearnNewDialog() {
this.closeDialogs();
this.showLearnDialog = true;
},
showImproveDialog() {
this.closeDialogs();
this.showImproveSelectionDialog = true;
},
openAddDialog() {
this.closeDialogs();
this.showAddDialog = true;
},
closeDialogs() {
this.showLearnDialog = false;
this.showImproveSelectionDialog = false;
this.showAddDialog = false;
this.showDetailedLearnDialog = false;
this.clearFormData();
},
clearFormData() {
this.selectedSkillToImprove = null;
this.usePP = 0;
this.improveNotes = '';
this.addSkillName = '';
this.addSkillValue = 0;
this.addNotes = '';
// Detailliertes Lernen zurücksetzen
this.selectedSkillToLearn = null;
this.selectedLearningType = 'improve';
},
handleSkillLearned(eventData) {
// Event-Handler für die neue SkillLearnDialog-Komponente
console.log('Fertigkeit gelernt:', eventData);
this.$emit('character-updated');
},
async improveSelectedSkill() {
if (!this.selectedSkillToImprove) {
alert('Bitte wählen Sie eine Fertigkeit aus.');
return;
}
this.isLoading = true;
try {
const response = await this.$api.post(`/api/characters/improve-skill`, {
char_id: this.character.id,
name: this.selectedSkillToImprove.name,
current_level: this.selectedSkillToImprove.fertigkeitswert,
target_level: this.selectedSkillToImprove.fertigkeitswert + 1,
type: 'skill',
action: 'improve',
reward: 'default',
use_pp: this.usePP || 0,
notes: this.improveNotes || `Fertigkeit ${this.selectedSkillToImprove.name} über Frontend verbessert`
});
console.log('Fertigkeit erfolgreich verbessert:', response.data);
alert(`Fertigkeit "${this.selectedSkillToImprove.name}" erfolgreich verbessert!`);
this.closeDialogs();
this.$emit('character-updated');
} catch (error) {
console.error('Fehler beim Verbessern der Fertigkeit:', error);
alert('Fehler beim Verbessern der Fertigkeit: ' + (error.response?.data?.error || error.message));
} finally {
this.isLoading = false;
}
},
async improveSkill(skill) {
// Detailliertes Lernformular über neue Komponente öffnen
this.selectedSkillToLearn = skill;
this.selectedLearningType = 'improve';
this.showDetailedLearnDialog = true;
},
async improveWeaponSkill(skill) {
// Waffenfertigkeit verbessern
this.isLoading = true;
try {
const response = await this.$api.post(`/api/characters/improve-skill`, {
char_id: this.character.id,
name: skill.name,
current_level: skill.fertigkeitswert,
target_level: skill.fertigkeitswert + 1,
type: 'skill',
action: 'improve',
reward: 'default',
use_pp: 0,
notes: `Waffenfertigkeit ${skill.name} direkt aus Tabelle verbessert`
});
console.log('Waffenfertigkeit erfolgreich verbessert:', response.data);
alert(`Waffenfertigkeit "${skill.name}" erfolgreich verbessert!`);
this.$emit('character-updated');
} catch (error) {
console.error('Fehler beim Verbessern der Waffenfertigkeit:', error);
alert('Fehler beim Verbessern der Waffenfertigkeit: ' + (error.response?.data?.error || error.message));
} finally {
this.isLoading = false;
}
},
async addNewSkill() {
if (!this.addSkillName.trim()) {
alert('Bitte geben Sie einen Fertigkeitsnamen ein.');
return;
}
if (this.addSkillValue < 0 || this.addSkillValue > 20) {
alert('Der Fertigkeitswert muss zwischen 0 und 20 liegen.');
return;
}
// TODO: Hier würde ein API-Endpunkt zum direkten Hinzufügen von Fertigkeiten benötigt
// Da dieser noch nicht existiert, verwenden wir eine Platzhalter-Implementierung
alert(`Fertigkeit "${this.addSkillName}" mit Wert ${this.addSkillValue} hinzugefügt! (Noch nicht implementiert)`);
this.closeDialogs();
},
handleSkillUpdated() {
// Event-Handler für die neue Dialog-Komponente
this.$emit('character-updated');
},
async increasePP(skill) {
try {
const response = await this.$api.post(`/api/characters/${this.character.id}/practice-points/add`, {
skill_name: skill.name,
amount: 1
});
// Verwende die Enhanced Response Daten
const data = response.data;
if (data.success) {
// Aktualisiere die lokalen Daten mit den Server-Daten
if (data.practice_points) {
// Aktualisiere alle Praxispunkte basierend auf der Server-Antwort
this.updateLocalPracticePoints(data.practice_points);
}
// Zeige informative Nachricht über Zauber-Detection
if (data.is_spell && data.requested_skill !== data.target_skill) {
console.log(`Zauber erkannt: PP für "${data.requested_skill}" wurde zu "${data.target_skill}" hinzugefügt`);
}
console.log('Praxispunkt hinzugefügt:', data.message);
// Charakter-Ansicht aktualisieren
this.$emit('character-updated');
}
} catch (error) {
console.error('Fehler beim Hinzufügen von Praxispunkten:', error);
alert('Fehler beim Hinzufügen von Praxispunkten: ' + (error.response?.data?.error || error.message));
}
},
async decreasePP(skill) {
if ((skill.pp || 0) <= 0) return;
try {
const response = await this.$api.post(`/api/characters/${this.character.id}/practice-points/use`, {
skill_name: skill.name,
amount: 1
});
// Verwende die Enhanced Response Daten
const data = response.data;
if (data.success) {
// Aktualisiere die lokalen Daten mit den Server-Daten
if (data.practice_points) {
// Aktualisiere alle Praxispunkte basierend auf der Server-Antwort
this.updateLocalPracticePoints(data.practice_points);
}
// Zeige informative Nachricht über Zauber-Detection
if (data.is_spell && data.requested_skill !== data.target_skill) {
console.log(`Zauber erkannt: PP für "${data.requested_skill}" wurde von "${data.target_skill}" verwendet`);
}
console.log('Praxispunkt entfernt:', data.message);
// Charakter-Ansicht aktualisieren
this.$emit('character-updated');
}
} catch (error) {
console.error('Fehler beim Entfernen von Praxispunkten:', error);
alert('Fehler beim Entfernen von Praxispunkten: ' + (error.response?.data?.error || error.message));
}
},
async increaseWeaponPP(skill) {
try {
const response = await this.$api.post(`/api/characters/${this.character.id}/practice-points/add`, {
skill_name: skill.name,
amount: 1
});
// Verwende die Enhanced Response Daten
const data = response.data;
if (data.success) {
// Aktualisiere die lokalen Daten mit den Server-Daten
if (data.practice_points) {
// Aktualisiere alle Praxispunkte basierend auf der Server-Antwort
this.updateLocalPracticePoints(data.practice_points);
}
console.log('Praxispunkt für Waffenfertigkeit hinzugefügt:', data.message);
// Charakter-Ansicht aktualisieren
this.$emit('character-updated');
}
} catch (error) {
console.error('Fehler beim Hinzufügen von Praxispunkten für Waffenfertigkeit:', error);
alert('Fehler beim Hinzufügen von Praxispunkten: ' + (error.response?.data?.error || error.message));
}
},
async decreaseWeaponPP(skill) {
if ((skill.pp || 0) <= 0) return;
try {
const response = await this.$api.post(`/api/characters/${this.character.id}/practice-points/use`, {
skill_name: skill.name,
amount: 1
});
// Verwende die Enhanced Response Daten
const data = response.data;
if (data.success) {
// Aktualisiere die lokalen Daten mit den Server-Daten
if (data.practice_points) {
// Aktualisiere alle Praxispunkte basierend auf der Server-Antwort
this.updateLocalPracticePoints(data.practice_points);
}
console.log('Praxispunkt für Waffenfertigkeit entfernt:', data.message);
// Charakter-Ansicht aktualisieren
this.$emit('character-updated');
}
} catch (error) {
console.error('Fehler beim Entfernen von Praxispunkten für Waffenfertigkeit:', error);
alert('Fehler beim Entfernen von Praxispunkten: ' + (error.response?.data?.error || error.message));
}
},
// Helper-Methode zum Aktualisieren der lokalen Praxispunkte basierend auf Server-Response
updateLocalPracticePoints(practicePointsFromServer) {
// Erstelle ein Map für schnellen Zugriff
const ppMap = {};
practicePointsFromServer.forEach(pp => {
ppMap[pp.skill_name] = pp.amount;
});
// Aktualisiere categorizedskills (Fertigkeiten nach Kategorien)
if (this.character.categorizedskills) {
Object.values(this.character.categorizedskills).forEach(skillCategory => {
if (Array.isArray(skillCategory)) {
skillCategory.forEach(skill => {
skill.pp = ppMap[skill.name] || 0;
});
}
});
}
// Aktualisiere Waffen-Fertigkeiten
if (this.character.waffenfertigkeiten) {
this.character.waffenfertigkeiten.forEach(skill => {
skill.pp = ppMap[skill.name] || 0;
});
}
// Aktualisiere auch flache fertigkeiten Liste falls vorhanden
if (this.character.fertigkeiten) {
this.character.fertigkeiten.forEach(skill => {
skill.pp = ppMap[skill.name] || 0;
});
}
},
// Inline editing methods
getSkillId(skill) {
// Create unique ID combining name and type
return `${skill.name}_${skill.kategorie || 'weapon'}`;
},
startEditSkill(skill, field) {
if (!this.isOwner) return;
this.editingSkillId = this.getSkillId(skill);
this.editingField = field;
this.editValue = skill[field] || '';
this.$nextTick(() => {
const input = this.$refs.editInput;
if (input) {
const element = Array.isArray(input) ? input[0] : input;
if (element) {
element.focus();
if (element.select) element.select();
}
}
});
},
async saveEditSkill(skill, field) {
if (this.editingSkillId === null) return;
let newValue = this.editValue;
// Validate and convert for fertigkeitswert
if (field === 'fertigkeitswert') {
newValue = parseInt(this.editValue);
if (isNaN(newValue) || newValue < 0 || newValue > 20) {
alert('Fertigkeitswert muss zwischen 0 und 20 liegen.');
this.cancelEditSkill();
return;
}
}
// Update local character object
skill[field] = newValue;
try {
// Save to backend
await API.put(`/api/characters/${this.character.id}`, this.character);
this.$emit('character-updated');
this.cancelEditSkill();
} catch (error) {
console.error('Failed to update skill:', error);
alert('Fehler beim Speichern: ' + (error.response?.data?.error || error.message));
this.cancelEditSkill();
}
},
cancelEditSkill() {
this.editingSkillId = null;
this.editingField = null;
this.editValue = '';
},
isEditingSkill(skill, field) {
return this.editingSkillId === this.getSkillId(skill) && this.editingField === field;
}
}
};
</script>
@@ -1,629 +0,0 @@
<template>
<div v-if="show" class="modal-overlay" @click.self="closeDialog">
<div class="modal-content modal-wide">
<h3>{{ $t('spells.learn.title') }}</h3>
<!-- Aktuelle Ressourcen -->
<div class="current-resources">
<div class="resource-display-card">
<span class="resource-icon"></span>
<div class="resource-info">
<div class="resource-label">Erfahrungspunkte</div>
<div class="resource-amount">{{ character.erfahrungsschatz?.ep || 0 }} EP</div>
<div class="resource-remaining">
<small :class="{ 'text-warning': remainingEP < 50, 'text-danger': remainingEP <= 0 }">
Verbleibend: {{ remainingEP }} EP
</small>
</div>
</div>
</div>
<div class="resource-display-card">
<span class="resource-icon">💰</span>
<div class="resource-info">
<div class="resource-label">Gold</div>
<div class="resource-amount">{{ character.vermoegen?.goldstücke || 0 }} GS</div>
<div class="resource-remaining">
<small :class="{ 'text-warning': remainingGold < 20, 'text-danger': remainingGold <= 0 }">
Nach Lernen: {{ remainingGold }} GS
</small>
</div>
</div>
</div>
</div>
<!-- Belohnungsart, Suche und Sortierung -->
<div class="form-group form-row">
<div class="form-col form-col-main">
<label>Lernen als Belohnung:</label>
<select v-model="selectedRewardType" :disabled="isLoadingRewardTypes">
<option value="" disabled>
{{ isLoadingRewardTypes ? 'Lade Belohnungsarten...' : 'Belohnungsart wählen' }}
</option>
<option
v-for="rewardType in availableRewardTypes"
:key="rewardType.value"
:value="rewardType.value"
>
{{ rewardType.label }}
</option>
</select>
</div>
<div class="form-col form-col-input">
<label>{{ $t('spells.learn.search.label') }}</label>
<input
v-model="searchTerm"
type="text"
:placeholder="$t('spells.learn.search.placeholder')"
/>
</div>
<div class="form-col form-col-input">
<label>Sortierung:</label>
<select v-model="sortBy">
<option value="name">Name</option>
<option value="epCost">EP Kosten</option>
<option value="goldCost">Gold Kosten</option>
</select>
</div>
</div>
<!-- Schule Buttons -->
<div class="form-group">
<label>{{ $t('spells.learn.school.label') }}</label>
<div class="school-buttons">
<button
class="school-btn"
:class="{ 'active': selectedSchool === '' }"
@click="selectedSchool = ''"
>
{{ $t('spells.learn.school.all') }}
</button>
<button
v-for="school in availableSchools"
:key="school"
class="school-btn"
:class="{ 'active': selectedSchool === school }"
@click="selectedSchool = school"
>
{{ school }}
</button>
</div>
</div>
<!-- Verfügbare Zauber und Zu lernende Zauber -->
<div class="spells-container">
<!-- Linke Spalte: Verfügbare Zauber -->
<div class="available-spells-section">
<div class="form-group">
<label>{{ $t('spells.learn.available') }}</label>
<div v-if="filteredSpells.length > 0" class="learning-levels">
<div
v-for="spell in filteredSpells"
:key="spell.name"
class="level-option"
:class="{
'selected': selectedSpell?.name === spell.name,
'already-selected': isSpellInLearningList(spell.name)
}"
@click="selectSpell(spell)"
>
<div class="level-header">
<span class="level-target">{{ spell.name }}</span>
<div class="spell-actions-inline">
<span class="level-cost">
<span v-if="spell.epCost">{{ spell.epCost }} EP</span>
<span v-if="spell.epCost && spell.goldCost"> + </span>
<span v-if="spell.goldCost">{{ spell.goldCost }} GS</span>
</span>
<button
@click.stop="addSpellToLearningListDirect(spell)"
class="btn-add-inline"
:disabled="isSpellInLearningList(spell.name) || !canAffordSpell(spell)"
:title="isSpellInLearningList(spell.name) ? 'Bereits in Liste' : 'Zur Liste hinzufügen'"
>
{{ isSpellInLearningList(spell.name) ? '✓' : '+' }}
</button>
</div>
</div>
</div>
</div>
<div v-else-if="!isLoading" class="no-spells">
Keine Zauber verfügbar
</div>
</div>
</div>
<!-- Rechte Spalte: Zu lernende Zauber -->
<div class="learning-list-section">
<div class="form-group">
<label>Zu lernende Zauber</label>
<div v-if="spellsToLearn.length > 0" class="learning-levels">
<div
v-for="(spell, index) in spellsToLearn"
:key="spell.name"
class="level-option learning-item"
>
<div class="level-header">
<span class="level-target">{{ spell.name }}</span>
<span class="level-cost">
<span v-if="spell.epCost">{{ spell.epCost }} EP</span>
<span v-if="spell.epCost && spell.goldCost"> + </span>
<span v-if="spell.goldCost">{{ spell.goldCost }} GS</span>
</span>
<button
@click="removeSpellFromLearningList(index)"
class="remove-btn"
title="Zauber aus Liste entfernen"
>
×
</button>
</div>
</div>
</div>
<div v-else class="no-spells">
Keine Zauber ausgewählt
</div>
<!-- Gesamtkosten -->
<div v-if="spellsToLearn.length > 0" class="total-costs">
<div class="cost-summary">
<strong>Gesamtkosten:</strong>
<span v-if="totalLearningCosts.ep > 0">{{ totalLearningCosts.ep }} EP</span>
<span v-if="totalLearningCosts.ep > 0 && totalLearningCosts.gold > 0"> + </span>
<span v-if="totalLearningCosts.gold > 0">{{ totalLearningCosts.gold }} GS</span>
</div>
</div>
</div>
</div>
</div>
<!-- Ausgewählter Zauber Aktionen und Details -->
<div v-if="selectedSpell" class="form-group">
<div class="spell-details-section">
<!--
<div class="selection-summary">
<div class="spell-actions">
<strong>Ausgewählt:</strong> {{ selectedSpell.name }}
<span class="cost-info">
- Kosten:
<span v-if="selectedSpell.epCost">{{ selectedSpell.epCost }} EP</span>
<span v-if="selectedSpell.epCost && selectedSpell.goldCost"> + </span>
<span v-if="selectedSpell.goldCost">{{ selectedSpell.goldCost }} GS</span>
</span>
<button
@click="addSpellToLearningList"
class="btn-add-spell"
:disabled="isSpellInLearningList(selectedSpell.name) || !canAffordSpell(selectedSpell)"
>
{{ isSpellInLearningList(selectedSpell.name) ? 'Bereits in Liste' : 'Zur Liste hinzufügen' }}
</button>
</div>
</div>
-->
<!-- Detaillierte Zauber-Informationen -->
<div v-if="isLoadingSpellDetails" class="loading-spell-details">
<span>Lade Zauber-Details...</span>
</div>
<div v-else-if="selectedSpellDetails" class="spell-details-grid">
<div class="spell-detail-card">
<h4>Grunddaten</h4>
<div class="detail-row">
<span class="detail-label">Stufe:</span>
<span class="detail-value">{{ selectedSpellDetails.level }}</span>
</div>
<div class="detail-row" v-if="selectedSpellDetails.bonus">
<span class="detail-label">Bonus:</span>
<span class="detail-value">+{{ selectedSpellDetails.bonus }}</span>
</div>
<div class="detail-row" v-if="selectedSpellDetails.category">
<span class="detail-label">Schule:</span>
<span class="detail-value">{{ selectedSpellDetails.category }}</span>
</div>
<div class="detail-row" v-if="selectedSpellDetails.ursprung">
<span class="detail-label">Ursprung:</span>
<span class="detail-value">{{ selectedSpellDetails.ursprung }}</span>
</div>
</div>
<div class="spell-detail-card" v-if="selectedSpellDetails.ap || selectedSpellDetails.art || selectedSpellDetails.zauberdauer">
<h4>Ausführung</h4>
<div class="detail-row" v-if="selectedSpellDetails.ap">
<span class="detail-label">AP:</span>
<span class="detail-value">{{ selectedSpellDetails.ap }}</span>
</div>
<div class="detail-row" v-if="selectedSpellDetails.art">
<span class="detail-label">Art:</span>
<span class="detail-value">{{ selectedSpellDetails.art }}</span>
</div>
<div class="detail-row" v-if="selectedSpellDetails.zauberdauer">
<span class="detail-label">Zauberdauer:</span>
<span class="detail-value">{{ selectedSpellDetails.zauberdauer }}</span>
</div>
</div>
<div class="spell-detail-card" v-if="selectedSpellDetails.reichweite || selectedSpellDetails.wirkungsziel || selectedSpellDetails.wirkungsbereich">
<h4>Reichweite & Ziel</h4>
<div class="detail-row" v-if="selectedSpellDetails.reichweite">
<span class="detail-label">Reichweite:</span>
<span class="detail-value">{{ selectedSpellDetails.reichweite }}</span>
</div>
<div class="detail-row" v-if="selectedSpellDetails.wirkungsziel">
<span class="detail-label">Wirkungsziel:</span>
<span class="detail-value">{{ selectedSpellDetails.wirkungsziel }}</span>
</div>
<div class="detail-row" v-if="selectedSpellDetails.wirkungsbereich">
<span class="detail-label">Wirkungsbereich:</span>
<span class="detail-value">{{ selectedSpellDetails.wirkungsbereich }}</span>
</div>
</div>
<div class="spell-detail-card" v-if="selectedSpellDetails.wirkungsdauer">
<h4>Wirkung</h4>
<div class="detail-row">
<span class="detail-label">Wirkungsdauer:</span>
<span class="detail-value">{{ selectedSpellDetails.wirkungsdauer }}</span>
</div>
</div>
</div>
<!-- Beschreibung -->
<div class="spell-description" v-if="selectedSpellDetails && selectedSpellDetails.beschreibung">
<h4>Beschreibung</h4>
<p>{{ selectedSpellDetails.beschreibung }}</p>
</div>
</div>
</div>
<div v-if="isLoading" class="loading-message">
{{ $t('common.loading') }}
</div>
<div class="modal-actions">
<button
@click="learnAllSpells"
class="btn-confirm"
:disabled="spellsToLearn.length === 0 || isLoading || !canAffordAllSpells"
>
{{ isLoading ? 'Wird gelernt...' : `${spellsToLearn.length} Zauber lernen` }}
</button>
<button @click="closeDialog" class="btn-cancel" :disabled="isLoading">
{{ $t('common.cancel') }}
</button>
</div>
</div>
</div>
</template>
<script>
import API from '@/utils/api'
export default {
name: "SpellLearnDialog",
props: {
show: {
type: Boolean,
required: true
},
character: {
type: Object,
required: true
}
},
emits: ['close', 'spell-learned'],
data() {
return {
searchTerm: '',
selectedSchool: '',
selectedRewardType: '',
sortBy: 'name',
spellsBySchool: {},
selectedSpell: null,
selectedSpellDetails: null,
spellsToLearn: [],
isLoading: false,
isLoadingSpellDetails: false,
availableRewardTypes: [],
isLoadingRewardTypes: false
};
},
computed: {
remainingEP() {
const currentEP = this.character.erfahrungsschatz?.ep || 0;
const usedEP = this.totalLearningCosts.ep;
return Math.max(0, currentEP - usedEP);
},
remainingGold() {
const currentGold = this.character.vermoegen?.goldstücke || 0;
const usedGold = this.totalLearningCosts.gold;
return Math.max(0, currentGold - usedGold);
},
totalLearningCosts() {
return this.spellsToLearn.reduce((total, spell) => {
total.ep += spell.epCost || 0;
total.gold += spell.goldCost || 0;
return total;
}, { ep: 0, gold: 0 });
},
canAffordAllSpells() {
const currentEP = this.character.erfahrungsschatz?.ep || 0;
const currentGold = this.character.vermoegen?.goldstücke || 0;
const costs = this.totalLearningCosts;
return currentEP >= costs.ep && currentGold >= costs.gold;
},
totalCosts() {
if (!this.selectedSpell) return 0;
return (this.selectedSpell.epCost || 0) + (this.selectedSpell.goldCost || 0);
},
filteredSpells() {
let allSpells = [];
// Sammle alle Zauber aus allen Schulen
Object.keys(this.spellsBySchool).forEach(school => {
if (!this.selectedSchool || school === this.selectedSchool) {
allSpells = allSpells.concat(this.spellsBySchool[school]);
}
});
// Filter nach Suchterm
let filtered = allSpells.filter(spell => {
const matchesSearch = !this.searchTerm ||
spell.name.toLowerCase().includes(this.searchTerm.toLowerCase());
return matchesSearch;
});
// Sortierung
return filtered.sort((a, b) => {
switch (this.sortBy) {
case 'epCost':
return (a.epCost || 0) - (b.epCost || 0);
case 'goldCost':
return (a.goldCost || 0) - (b.goldCost || 0);
case 'name':
default:
return a.name.localeCompare(b.name);
}
});
},
availableSchools() {
return Object.keys(this.spellsBySchool).sort();
}
},
watch: {
show(newVal) {
if (newVal) {
this.resetForm();
this.loadRewardTypes();
}
},
selectedRewardType() {
if (this.selectedRewardType) {
this.loadAvailableSpells();
}
}
},
created() {
this.$api = API;
},
methods: {
closeDialog() {
this.$emit('close');
},
resetForm() {
this.searchTerm = '';
this.selectedSchool = '';
this.selectedRewardType = '';
this.sortBy = 'name';
this.spellsBySchool = {};
this.selectedSpell = null;
this.selectedSpellDetails = null;
this.spellsToLearn = [];
this.availableRewardTypes = [];
},
isSpellInLearningList(spellName) {
return this.spellsToLearn.some(spell => spell.name === spellName);
},
canAffordSpell(spell) {
const currentEP = this.character.erfahrungsschatz?.ep || 0;
const currentGold = this.character.vermoegen?.goldstücke || 0;
const totalCostsWithSpell = {
ep: this.totalLearningCosts.ep + (spell.epCost || 0),
gold: this.totalLearningCosts.gold + (spell.goldCost || 0)
};
return currentEP >= totalCostsWithSpell.ep && currentGold >= totalCostsWithSpell.gold;
},
addSpellToLearningList() {
if (!this.selectedSpell || this.isSpellInLearningList(this.selectedSpell.name)) return;
if (!this.canAffordSpell(this.selectedSpell)) return;
this.spellsToLearn.push({ ...this.selectedSpell });
this.selectedSpell = null;
},
addSpellToLearningListDirect(spell) {
if (this.isSpellInLearningList(spell.name)) return;
if (!this.canAffordSpell(spell)) return;
this.spellsToLearn.push({ ...spell });
},
removeSpellFromLearningList(index) {
this.spellsToLearn.splice(index, 1);
},
async loadRewardTypes() {
const token = localStorage.getItem('token');
if (!token) {
console.error('No authentication token available');
this.availableRewardTypes = [{
value: 'error',
label: 'Anmeldung erforderlich'
}];
return;
}
this.isLoadingRewardTypes = true;
try {
const response = await this.$api.get(`/api/characters/${this.character.id}/reward-types`, {
params: {
learning_type: 'spell',
skill_name: 'spell',
current_level: 0,
skill_type: 'spell'
}
});
this.availableRewardTypes = response.data.reward_types || [];
// Setze Default-Belohnungsart wenn verfügbar
if (this.availableRewardTypes.length > 0 && !this.selectedRewardType) {
const defaultReward = this.availableRewardTypes.find(r => r.value === 'default');
this.selectedRewardType = defaultReward ? defaultReward.value : this.availableRewardTypes[0].value;
}
} catch (error) {
console.error('Fehler beim Laden der Belohnungsarten:', error);
this.availableRewardTypes = [{
value: 'default',
label: 'Standard'
}];
this.selectedRewardType = 'default';
} finally {
this.isLoadingRewardTypes = false;
}
},
performInitialSearch() {
// Beim Öffnen alle Zauber laden
this.loadAvailableSpells();
},
async loadAvailableSpells() {
if (!this.selectedRewardType) return;
try {
this.isLoading = true;
// Erstelle LernCostRequest wie vom Backend erwartet
const request = {
char_id: this.character.id,
type: 'spell',
action: 'learn',
use_pp: 0,
use_gold: 0,
reward: this.selectedRewardType
};
const response = await this.$api.post('/api/characters/available-spells-new', request);
this.spellsBySchool = response.data.spells_by_school || {};
} catch (error) {
console.error('Fehler beim Laden der verfügbaren Zauber:', error);
this.spellsBySchool = {};
} finally {
this.isLoading = false;
}
},
async loadSpellDetails(spellName) {
if (!spellName) return;
try {
this.isLoadingSpellDetails = true;
const response = await this.$api.get('/api/characters/spell-details', {
params: { name: spellName }
});
this.selectedSpellDetails = response.data.spell;
} catch (error) {
console.error('Fehler beim Laden der Zauber-Details:', error);
this.selectedSpellDetails = null;
} finally {
this.isLoadingSpellDetails = false;
}
},
selectSpell(spell) {
const wasSelected = this.selectedSpell?.name === spell.name;
this.selectedSpell = wasSelected ? null : spell;
if (this.selectedSpell) {
// Lade Details nur wenn der Zauber ausgewählt wurde
this.loadSpellDetails(this.selectedSpell.name);
} else {
// Leerere Details wenn kein Zauber ausgewählt ist
this.selectedSpellDetails = null;
}
},
async learnAllSpells() {
if (this.spellsToLearn.length === 0 || this.isLoading || !this.canAffordAllSpells) return;
try {
this.isLoading = true;
const responses = [];
// Lerne jeden Zauber einzeln
for (const spell of this.spellsToLearn) {
const response = await this.$api.post(`/api/characters/${this.character.id}/learn-spell-new`, {
char_id: this.character.id,
name: spell.name,
type: 'spell',
action: 'learn',
use_pp: 0,
use_gold: 0,
reward: this.selectedRewardType
});
responses.push({
spell: spell,
response: response.data
});
}
// Erfolgsmeldung mit Details
const learnedCount = responses.length;
this.$emit('spell-learned', {
spells: this.spellsToLearn,
responses: responses,
count: learnedCount
});
// Dialog schließen nach erfolgreichem Lernen
this.closeDialog();
} catch (error) {
console.error('Fehler beim Lernen der Zauber:', error);
alert('Fehler beim Lernen der Zauber: ' + (error.response?.data?.message || error.message));
} finally {
this.isLoading = false;
}
}
}
};
</script>
<style scoped>
/* Component-specific styles - common styles are in main.css */
.modal-content {
width: 100vw;
height: 100vh;
max-width: 100vw;
max-height: 100vh;
box-sizing: border-box;
}
.modal-wide {
max-width: 100vw;
}
</style>
-132
View File
@@ -1,132 +0,0 @@
<template>
<div class="fullwidth-container">
<!-- Header mit Lernmodus-Kontrollen -->
<div v-if="isOwner" class="page-header header-section">
<div class="learning-mode-controls">
<!-- Lernmodus Toggle Button -->
<button
@click="showLearnNewDialog"
class="btn btn-primary btn-learning-mode"
title="Neuen Zauber lernen"
>
<span class="icon">🎓</span>
</button>
</div>
</div>
<div class="cd-list">
<table class="cd-table">
<thead>
<tr class="cd-table-header">
<th>{{ $t('spell.name') }}</th>
<th>{{ $t('spell.description') }}</th>
<th>{{ $t('spell.bonus') }}</th>
<th>{{ $t('spell.quelle') }}</th>
</tr>
</thead>
<tbody>
<template v-for="spell in character.zauber" :key="spell.id || spell.name">
<tr>
<td>{{ spell.name || '-' }}</td>
<td>{{ spell.beschreibung || '-' }}</td>
<td>{{ spell.bonus || '0' }}</td>
<td>{{ spell.quelle || '-' }}</td>
</tr>
</template>
</tbody>
</table>
</div> <!--- end cd-list-->
<!-- Dialog für neue Zauber lernen -->
<SpellLearnDialog
:character="character"
:show="showLearnDialog"
@close="closeDialogs"
@spell-learned="handleSpellLearned"
/>
</div> <!--- end character -datasheet-->
</template>
<style>
/* SpellView spezifische Styles */
.cd-table-header {
background-color: #1da766;
color: white;
font-weight: bold;
}
.header-section {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.learning-mode-controls {
display: flex;
align-items: center;
gap: 15px;
}
.btn-learning-mode {
display: flex;
align-items: center;
gap: 5px;
border: 2px solid #1da766;
}
.btn-learning-mode:hover {
background: #1da766;
color: white;
}
.icon {
font-size: 1.2em;
}
</style>
<script>
import API from '@/utils/api'
import SpellLearnDialog from './SpellLearnDialog.vue'
export default {
name: "SpellView",
components: {
SpellLearnDialog
},
props: {
character: {
type: Object,
required: true
},
isOwner: {
type: Boolean,
default: false
}
},
data() {
return {
showLearnDialog: false,
isLoading: false
};
},
created() {
this.$api = API;
},
methods: {
showLearnNewDialog() {
this.showLearnDialog = true;
},
closeDialogs() {
this.showLearnDialog = false;
},
handleSpellLearned(eventData) {
this.$emit('character-updated');
this.closeDialogs();
}
}
};
</script>
@@ -1,514 +0,0 @@
<template>
<div v-if="showDialog" class="modal-overlay" @click.self="closeDialog">
<div class="modal-content modal-large">
<div class="modal-header">
<h3>{{ $t('visibility.title') }}</h3>
<button @click="closeDialog" class="close-button">&times;</button>
</div>
<div class="modal-body">
<div v-if="isUpdating" class="loading-overlay">
<div class="spinner"></div>
<p>{{ $t('visibility.updating') }}</p>
</div>
<!-- Visibility Options -->
<div class="form-group">
<p>{{ $t('visibility.description') }}</p>
<div class="visibility-options">
<label class="radio-option">
<input
type="radio"
:value="false"
v-model="isPublic"
:disabled="isUpdating"
>
<div class="option-content">
<span class="option-label">{{ $t('visibility.private') }}</span>
<span class="option-description">{{ $t('visibility.privateDescription') }}</span>
</div>
</label>
<label class="radio-option">
<input
type="radio"
:value="true"
v-model="isPublic"
:disabled="isUpdating"
>
<div class="option-content">
<span class="option-label">{{ $t('visibility.public') }}</span>
<span class="option-description">{{ $t('visibility.publicDescription') }}</span>
</div>
</label>
</div>
</div>
<!-- Share with Specific Users Section -->
<div class="form-group">
<h4>{{ $t('visibility.shareWithUsers') }}</h4>
<p class="section-description">{{ $t('visibility.shareDescription') }}</p>
<div class="share-sections-container">
<!-- Add Users Section -->
<div class="add-users-section">
<h5>{{ $t('visibility.addUsers') }}</h5>
<div class="user-search">
<input
v-model="searchQuery"
type="text"
:placeholder="$t('visibility.searchUsers')"
class="form-control"
:disabled="isUpdating"
/>
</div>
<div v-if="isLoadingUsers" class="loading">{{ $t('visibility.loadingUsers') }}</div>
<div v-else-if="filteredAvailableUsers.length > 0" class="available-users-list">
<div
v-for="user in filteredAvailableUsers"
:key="user.user_id"
class="user-item"
@click="toggleUser(user.user_id)"
>
<div class="user-info">
<span class="user-name">{{ user.display_name || user.username }}</span>
<span class="user-email">{{ user.email }}</span>
</div>
</div>
</div>
<div v-else-if="!isLoadingUsers && availableUsers.length === 0" class="no-users">
{{ $t('visibility.noOtherUsers') }}
</div>
<div v-else class="no-users">
{{ $t('visibility.noMatchingUsers') }}
</div>
</div>
<!-- Currently Shared Users -->
<div class="shared-users-list">
<h5>{{ $t('visibility.currentlySharedWith') }}</h5>
<div v-if="sharedUserIds.length > 0" class="shared-users-items">
<div
v-for="userId in sharedUserIds"
:key="userId"
class="user-item shared-user"
>
<div class="user-info">
<span class="user-name">{{ getUserName(userId) }}</span>
<span class="user-email">{{ getUserEmail(userId) }}</span>
</div>
<button @click="removeUser(userId)" class="remove-btn" :disabled="isUpdating">&times;</button>
</div>
</div>
<div v-else class="no-users">
{{ $t('visibility.noSharedUsers') }}
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button @click="updateVisibilityAndShares" class="btn-primary btn-save" :disabled="isUpdating">
<span v-if="!isUpdating">{{ $t('common.save') }}</span>
<span v-else>{{ $t('common.saving') }}</span>
</button>
<button @click="closeDialog" class="btn-cancel" :disabled="isUpdating">
{{ $t('common.cancel') }}
</button>
</div>
</div>
</div>
</template>
<script>
import API from '../utils/api'
export default {
name: "VisibilityDialog",
props: {
characterId: {
type: [String, Number],
required: true
},
currentVisibility: {
type: Boolean,
default: false
},
showDialog: {
type: Boolean,
default: false
}
},
data() {
return {
isPublic: false,
isUpdating: false,
isLoadingUsers: false,
availableUsers: [],
sharedUserIds: [],
searchQuery: ''
}
},
computed: {
filteredAvailableUsers() {
let users = this.availableUsers.filter(user => !this.sharedUserIds.includes(user.user_id))
if (!this.searchQuery) {
return users
}
const query = this.searchQuery.toLowerCase()
return users.filter(user =>
(user.display_name && user.display_name.toLowerCase().includes(query)) ||
user.username.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query)
)
}
},
watch: {
currentVisibility: {
immediate: true,
handler(newValue) {
this.isPublic = newValue
}
},
showDialog(newValue) {
if (newValue) {
this.isPublic = this.currentVisibility
this.loadAvailableUsers()
this.loadCurrentShares()
}
}
},
methods: {
async loadAvailableUsers() {
this.isLoadingUsers = true
try {
const token = localStorage.getItem('token')
const response = await API.get(`/api/characters/${this.characterId}/available-users`, {
headers: { Authorization: `Bearer ${token}` }
})
this.availableUsers = response.data || []
} catch (error) {
console.error('Failed to load available users:', error)
} finally {
this.isLoadingUsers = false
}
},
async loadCurrentShares() {
try {
const token = localStorage.getItem('token')
const response = await API.get(`/api/characters/${this.characterId}/shares`, {
headers: { Authorization: `Bearer ${token}` }
})
this.sharedUserIds = (response.data || []).map(share => share.user_id)
} catch (error) {
console.error('Failed to load current shares:', error)
this.sharedUserIds = []
}
},
toggleUser(userId) {
const index = this.sharedUserIds.indexOf(userId)
if (index > -1) {
this.sharedUserIds.splice(index, 1)
} else {
this.sharedUserIds.push(userId)
}
},
removeUser(userId) {
const index = this.sharedUserIds.indexOf(userId)
if (index > -1) {
this.sharedUserIds.splice(index, 1)
}
},
getUserName(userId) {
const user = this.availableUsers.find(u => u.user_id === userId)
return user ? (user.display_name || user.username) : 'Unknown'
},
getUserEmail(userId) {
const user = this.availableUsers.find(u => u.user_id === userId)
return user ? user.email : ''
},
closeDialog() {
if (!this.isUpdating) {
this.searchQuery = ''
this.$emit('update:showDialog', false)
}
},
async updateVisibilityAndShares() {
this.isUpdating = true
try {
const token = localStorage.getItem('token')
// Update visibility
await API.patch(`/api/characters/${this.characterId}`,
{ public: this.isPublic },
{
headers: { Authorization: `Bearer ${token}` }
}
)
// Update shares
await API.put(`/api/characters/${this.characterId}/shares`,
{ user_ids: this.sharedUserIds },
{
headers: { Authorization: `Bearer ${token}` }
}
)
this.$emit('visibility-updated', this.isPublic)
this.closeDialog()
} catch (error) {
console.error('Failed to update character visibility/shares:', error)
alert(this.$t('visibility.updateError') + ': ' + (error.response?.data?.error || error.message))
} finally {
this.isUpdating = false
}
}
}
}
</script>
<style scoped>
.visibility-options {
display: flex;
flex-direction: column;
gap: 15px;
margin-top: 15px;
}
.radio-option {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 15px;
border: 2px solid #dee2e6;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.radio-option:hover {
border-color: #007bff;
background-color: #f8f9fa;
}
.radio-option input[type="radio"] {
margin-top: 3px;
cursor: pointer;
}
.option-content {
display: flex;
flex-direction: column;
gap: 5px;
flex: 1;
}
.option-label {
font-weight: 600;
font-size: 1.1rem;
color: #333;
}
.option-description {
font-size: 0.9rem;
color: #666;
}
.radio-option input[type="radio"]:checked + .option-content .option-label {
color: #007bff;
}
.radio-option:has(input[type="radio"]:checked) {
border-color: #007bff;
background-color: #e7f3ff;
}
.modal-large {
max-width: 700px;
max-height: 90vh;
overflow-y: auto;
}
.section-description {
color: #666;
font-size: 0.9rem;
margin-bottom: 15px;
}
.share-sections-container {
display: flex;
gap: 20px;
align-items: flex-start;
}
.shared-users-list {
flex: 1;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
min-height: 400px;
}
.shared-users-list h5 {
margin: 0 0 10px 0;
color: #333;
font-size: 1rem;
}
.shared-users-items {
border: 1px solid #dee2e6;
border-radius: 8px;
max-height: 300px;
overflow-y: auto;
background: white;
}
.user-item.shared-user {
background: white;
cursor: default;
}
.user-item.shared-user:hover {
background: #fff5f5;
}
.user-item.shared-user .remove-btn {
background: none;
border: none;
color: #dc3545;
font-size: 1.5rem;
cursor: pointer;
padding: 0 8px;
line-height: 1;
transition: color 0.2s ease;
font-weight: bold;
flex-shrink: 0;
}
.user-item.shared-user .remove-btn:hover {
color: #a71d2a;
}
.user-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.user-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: white;
border: 1px solid #dee2e6;
border-radius: 20px;
font-size: 0.9rem;
}
.user-chip .remove-btn {
background: none;
border: none;
color: #dc3545;
font-size: 1.2rem;
cursor: pointer;
padding: 0;
line-height: 1;
transition: color 0.2s ease;
}
.user-chip .remove-btn:hover {
color: #a71d2a;
}
.add-users-section {
flex: 1;
}
.add-users-section h5 {
margin: 0 0 10px 0;
color: #333;
font-size: 1rem;
}
.user-search {
margin-bottom: 15px;
}
.available-users-list {
max-height: 300px;
overflow-y: auto;
border: 1px solid #dee2e6;
border-radius: 8px;
}
.user-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 15px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background 0.2s ease;
}
.user-item:last-child {
border-bottom: none;
}
.user-item:hover {
background: #f8f9fa;
}
.user-item.selected {
background: #e7f3ff;
border-left: 3px solid #007bff;
}
.user-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.user-name {
font-weight: 600;
color: #333;
}
.user-email {
font-size: 0.85rem;
color: #666;
}
.check-icon {
color: #28a745;
font-size: 1.2rem;
font-weight: bold;
}
.no-users {
text-align: center;
padding: 40px 20px;
color: #999;
font-style: italic;
}
.loading {
text-align: center;
padding: 20px;
color: #666;
}
</style>
-553
View File
@@ -1,553 +0,0 @@
<template>
<div class="cd-view">
<div class="header-section">
<h2>{{ $t('WeaponView') }}</h2>
<button v-if="isOwner" @click="openAddWeaponDialog" class="btn-add-weapon">
{{ $t('weapon.add') }}
</button>
</div>
<div class="cd-list">
<table class="cd-table">
<thead>
<tr>
<th>{{ $t('weapon.name') }}</th>
<th>{{ $t('weapon.description') }}</th>
<th>{{ $t('weapon.weight') }}</th>
<th>{{ $t('weapon.value') }}</th>
<th>{{ $t('weapon.amount') }}</th>
<th>{{ $t('weapon.contained_in') }}</th>
<th>{{ $t('weapon.bonus') }}</th>
<th v-if="isOwner">{{ $t('weapon.actions') }}</th>
</tr>
</thead>
<tbody>
<template v-if="character.waffen && character.waffen.length > 0">
<tr v-for="weapon in character.waffen" :key="weapon.id">
<td>{{ weapon.name || '-' }}<span v-if="weapon.ist_magisch" class="magic-indicator">*</span></td>
<td>{{ weapon.beschreibung || '-' }}</td>
<td>{{ weapon.gewicht || '-' }}</td>
<td>{{ weapon.wert || '-' }}</td>
<td>{{ weapon.anzahl || '-' }}</td>
<td>{{ weapon.beinhaltet_in || '-' }}</td>
<td>{{ weapon.anb || '-' }}/{{ weapon.abwb || '-' }}</td>
<td v-if="isOwner" class="action-cell">
<button @click="editWeapon(weapon)" class="btn-edit" title="Bearbeiten">
</button>
<button @click="deleteWeapon(weapon)" class="btn-delete" title="Löschen">
🗑
</button>
</td>
</tr>
</template>
<template v-else>
<tr>
<td colspan="8" class="empty-state">{{ $t('weapon.noWeapons') }}</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Dialog für Waffe hinzufügen -->
<div v-if="showAddDialog" class="modal-overlay" @click.self="closeDialog">
<div class="modal-content">
<div class="modal-header">
<h3>{{ $t('weapon.addWeapon') }}</h3>
<button @click="closeDialog" class="close-button">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>{{ $t('weapon.search') }}:</label>
<input
v-model="searchQuery"
type="text"
:placeholder="$t('weapon.searchPlaceholder')"
@input="filterWeapons"
/>
</div>
<div v-if="isLoading" class="loading">{{ $t('weapon.loading') }}</div>
<div v-else-if="filteredMasterWeapons.length > 0" class="weapon-list">
<div
v-for="weapon in filteredMasterWeapons"
:key="weapon.id"
class="weapon-item"
:class="{ selected: selectedWeapon && selectedWeapon.id === weapon.id }"
@click="selectWeapon(weapon)"
>
<div class="weapon-name">{{ weapon.name }}</div>
<div class="weapon-details">
{{ weapon.damage || '-' }} |
{{ weapon.weight }}kg |
{{ weapon.value || '-' }}
</div>
</div>
</div>
<div v-else class="no-results">
{{ searchQuery ? $t('weapon.noResults') : $t('weapon.noMasterData') }}
</div>
<div v-if="selectedWeapon" class="selected-weapon-details">
<h4>{{ $t('weapon.selectedWeapon') }}</h4>
<p><strong>{{ $t('weapon.name') }}:</strong> {{ selectedWeapon.name }}</p>
<p><strong>{{ $t('weapon.damage') }}:</strong> {{ selectedWeapon.damage || '-' }}</p>
<p><strong>{{ $t('weapon.weight') }}:</strong> {{ selectedWeapon.weight }}kg</p>
<p><strong>{{ $t('weapon.value') }}:</strong> {{ selectedWeapon.value || '-' }}</p>
<div class="form-group">
<label>{{ $t('weapon.amount') }}:</label>
<input v-model.number="weaponAmount" type="number" min="1" value="1" />
</div>
</div>
</div>
<div class="modal-footer">
<button @click="closeDialog" class="btn-cancel">{{ $t('weapon.cancel') }}</button>
<button
@click="addWeapon"
class="btn-confirm"
:disabled="!selectedWeapon || isSubmitting"
>
{{ isSubmitting ? $t('weapon.adding') : $t('weapon.add') }}
</button>
</div>
</div>
</div>
<!-- Dialog für Waffe bearbeiten -->
<div v-if="showEditDialog" class="modal-overlay" @click.self="closeEditDialog">
<div class="modal-content">
<div class="modal-header">
<h3>{{ $t('weapon.editWeapon') }}</h3>
<button @click="closeEditDialog" class="close-button">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>{{ $t('weapon.name') }}:</label>
<input v-model="editingWeapon.name" type="text" disabled />
</div>
<div class="form-group">
<label>{{ $t('weapon.description') }}:</label>
<textarea v-model="editingWeapon.beschreibung" rows="3"></textarea>
</div>
<div class="form-group">
<label>
<input type="checkbox" v-model="editingWeapon.ist_magisch" />
{{ $t('weapon.magical') }}
</label>
</div>
<div class="form-row">
<div class="form-group">
<label>{{ $t('weapon.amount') }}:</label>
<input v-model.number="editingWeapon.anzahl" type="number" min="1" />
</div>
<div class="form-group">
<label>{{ $t('weapon.value') }}:</label>
<input v-model.number="editingWeapon.wert" type="number" min="0" step="0.01" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>{{ $t('weapon.attackBonus') }} (Anb):</label>
<input v-model.number="editingWeapon.anb" type="number" />
</div>
<div class="form-group">
<label>{{ $t('weapon.defenseBonus') }} (Abwb):</label>
<input v-model.number="editingWeapon.abwb" type="number" />
</div>
</div>
<div class="form-group">
<label>{{ $t('weapon.damageBonus') }} (Schb):</label>
<input v-model.number="editingWeapon.schb" type="number" />
</div>
</div>
<div class="modal-footer">
<button @click="closeEditDialog" class="btn-cancel">{{ $t('weapon.cancel') }}</button>
<button
@click="saveWeapon"
class="btn-confirm"
:disabled="isSubmitting"
>
{{ isSubmitting ? $t('weapon.saving') : $t('weapon.save') }}
</button>
</div>
</div>
</div>
</div>
</template>
<style>
/* All common styles moved to main.css */
.cd-view {
padding: 1rem;
}
.header-section {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.btn-add-weapon {
padding: 8px 16px;
background: #1da766;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
transition: background 0.2s ease;
}
.btn-add-weapon:hover {
background: #16a085;
}
.cd-table {
width: 100%;
border-collapse: collapse;
}
.cd-table th,
.cd-table td {
padding: 8px;
text-align: left;
border-bottom: 1px solid #ddd;
}
.cd-table th {
background-color: #1da766;
color: white;
font-weight: bold;
}
.empty-state {
text-align: center;
color: #999;
font-style: italic;
padding: 2rem !important;
}
.magic-indicator {
color: #9c27b0;
font-weight: bold;
margin-left: 4px;
}
.action-cell {
text-align: center;
}
.btn-edit {
background: none;
border: none;
cursor: pointer;
font-size: 1.2rem;
padding: 4px 8px;
margin-right: 8px;
transition: transform 0.2s ease;
}
.btn-edit:hover {
transform: scale(1.2);
}
.btn-delete {
background: none;
border: none;
cursor: pointer;
font-size: 1.2rem;
padding: 4px 8px;
transition: transform 0.2s ease;
}
.btn-delete:hover {
transform: scale(1.2);
}
.form-row {
display: flex;
gap: 15px;
margin-bottom: 15px;
}
.form-row .form-group {
flex: 1;
margin-bottom: 0;
}
.form-group textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
resize: vertical;
font-family: inherit;
}
.loading {
text-align: center;
padding: 2rem;
color: #666;
}
.weapon-list {
max-height: 300px;
overflow-y: auto;
border: 1px solid #ddd;
border-radius: 4px;
margin-bottom: 15px;
}
.weapon-item {
padding: 12px;
border-bottom: 1px solid #eee;
cursor: pointer;
transition: background 0.2s ease;
}
.weapon-item:hover {
background: #f5f5f5;
}
.weapon-item.selected {
background: #e8f5e9;
border-left: 3px solid #1da766;
}
.weapon-name {
font-weight: bold;
color: #333;
margin-bottom: 4px;
}
.weapon-details {
font-size: 0.9em;
color: #666;
}
.no-results {
text-align: center;
padding: 2rem;
color: #999;
font-style: italic;
}
.selected-weapon-details {
background: #f8f9fa;
padding: 15px;
border-radius: 4px;
margin-top: 15px;
}
.selected-weapon-details h4 {
margin-top: 0;
color: #1da766;
}
.selected-weapon-details p {
margin: 8px 0;
}
</style>
<script>
import API from '@/utils/api'
export default {
name: "WeaponView",
props: {
character: {
type: Object,
required: true
},
isOwner: {
type: Boolean,
default: false
}
},
data() {
return {
showAddDialog: false,
showEditDialog: false,
isLoading: false,
isSubmitting: false,
masterWeapons: [],
filteredMasterWeapons: [],
selectedWeapon: null,
editingWeapon: null,
searchQuery: '',
weaponAmount: 1
}
},
created() {
this.$api = API
},
methods: {
async openAddWeaponDialog() {
this.showAddDialog = true
this.isLoading = true
try {
const response = await this.$api.get('/api/maintenance/weapons')
this.masterWeapons = response.data || []
this.filteredMasterWeapons = this.masterWeapons
} catch (error) {
console.error('Fehler beim Laden der Waffen-Stammdaten:', error)
alert(this.$t('weapon.loadError') + ': ' + (error.response?.data?.error || error.message))
} finally {
this.isLoading = false
}
},
filterWeapons() {
const query = this.searchQuery.toLowerCase().trim()
if (!query) {
this.filteredMasterWeapons = this.masterWeapons
} else {
this.filteredMasterWeapons = this.masterWeapons.filter(weapon =>
weapon.name.toLowerCase().includes(query) ||
(weapon.beschreibung && weapon.beschreibung.toLowerCase().includes(query))
)
}
},
selectWeapon(weapon) {
this.selectedWeapon = weapon
},
async addWeapon() {
if (!this.selectedWeapon) {
alert(this.$t('weapon.pleaseSelect'))
return
}
this.isSubmitting = true
try {
const weaponData = {
character_id: this.character.id,
name: this.selectedWeapon.name,
beschreibung: this.selectedWeapon.beschreibung || '',
gewicht: this.selectedWeapon.weight || 0,
wert: this.selectedWeapon.value || 0,
anzahl: this.weaponAmount,
ist_magisch: false,
anb: this.selectedWeapon.attack_bonus || 0,
abwb: this.selectedWeapon.defense_bonus || 0,
schb: this.selectedWeapon.damage_bonus || 0,
beinhaltet_in: '',
contained_in: 0,
container_type: ''
}
await this.$api.post('/api/weapons', weaponData)
alert(this.$t('weapon.addSuccess'))
this.closeDialog()
this.$emit('character-updated')
} catch (error) {
console.error('Fehler beim Hinzufügen der Waffe:', error)
alert(this.$t('weapon.addError') + ': ' + (error.response?.data?.error || error.message))
} finally {
this.isSubmitting = false
}
},
editWeapon(weapon) {
this.editingWeapon = {
id: weapon.id,
name: weapon.name,
beschreibung: weapon.beschreibung || '',
ist_magisch: weapon.ist_magisch || false,
anzahl: weapon.anzahl || 1,
wert: weapon.wert || 0,
anb: weapon.anb || 0,
abwb: weapon.abwb || 0,
schb: weapon.schb || 0
}
this.showEditDialog = true
},
async saveWeapon() {
if (!this.editingWeapon) {
return
}
this.isSubmitting = true
try {
const weaponData = {
beschreibung: this.editingWeapon.beschreibung,
ist_magisch: this.editingWeapon.ist_magisch,
anzahl: this.editingWeapon.anzahl,
wert: this.editingWeapon.wert,
anb: this.editingWeapon.anb,
abwb: this.editingWeapon.abwb,
schb: this.editingWeapon.schb
}
await this.$api.put(`/api/weapons/${this.editingWeapon.id}`, weaponData)
alert(this.$t('weapon.editSuccess'))
this.closeEditDialog()
this.$emit('character-updated')
} catch (error) {
console.error('Fehler beim Speichern der Waffe:', error)
alert(this.$t('weapon.editError') + ': ' + (error.response?.data?.error || error.message))
} finally {
this.isSubmitting = false
}
},
async deleteWeapon(weapon) {
if (!confirm(this.$t('weapon.confirmDelete').replace('{name}', weapon.name))) {
return
}
try {
await this.$api.delete(`/api/weapons/${weapon.id}`)
alert(this.$t('weapon.deleteSuccess'))
this.$emit('character-updated')
} catch (error) {
console.error('Fehler beim Löschen der Waffe:', error)
alert(this.$t('weapon.deleteError') + ': ' + (error.response?.data?.error || error.message))
}
},
closeEditDialog() {
this.showEditDialog = false
this.editingWeapon = null
},
closeDialog() {
this.showAddDialog = false
this.selectedWeapon = null
this.searchQuery = ''
this.weaponAmount = 1
this.filteredMasterWeapons = []
this.masterWeapons = []
}
}
}
</script>
@@ -1,419 +0,0 @@
<template>
<div class="header-section">
<h2>{{ $t('maintenance') }} - {{ $t('believe.title') }}</h2>
<div class="search-box">
<input
v-model="searchTerm"
type="text"
:placeholder="$t('search')"
/>
<button class="btn-primary" @click="startCreate">{{ $t('newEntry') }}</button>
</div>
</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('believe.id') }}</th>
<th class="cd-table-header">{{ $t('believe.name') }}</th>
<th class="cd-table-header">{{ $t('believe.description') }}</th>
<th class="cd-table-header">{{ $t('believe.source') }}</th>
<th class="cd-table-header">{{ $t('believe.page') }}</th>
<th class="cd-table-header">{{ $t('believe.system') }}</th>
<th class="cd-table-header"></th>
</tr>
</thead>
<tbody>
<tr v-if="isLoading">
<td colspan="7">{{ $t('common.loading') }}</td>
</tr>
<tr v-if="creatingNew">
<td>New</td>
<td colspan="6">
<div class="edit-form">
<div class="edit-row">
<label>{{ $t('believe.name') }}</label>
<input v-model="newItem.name" />
</div>
<div class="edit-row">
<label>{{ $t('believe.description') }}</label>
<input v-model="newItem.beschreibung" />
</div>
<div class="edit-row">
<label>{{ $t('believe.source') }}</label>
<select v-model="newItem.sourceCode">
<option value="">-</option>
<option v-for="source in sources" :key="source.id" :value="source.code">
{{ source.code }}
</option>
</select>
<label class="inline-label">{{ $t('believe.page') }}</label>
<input v-model.number="newItem.page_number" type="number" min="0" />
</div>
<div class="edit-row">
<label>{{ $t('believe.system') }}</label>
<select v-model.number="createSelectedSystemId">
<option value="">-</option>
<option v-for="system in systemOptions" :key="system.id" :value="system.id">
{{ system.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="believe in filteredBelieves" :key="believe.id">
<tr v-if="editingId !== believe.id">
<td>{{ believe.id }}</td>
<td>{{ believe.name }}</td>
<td>{{ believe.beschreibung || '-' }}</td>
<td>{{ getSourceCode(believe.source_id) || '-' }}</td>
<td>{{ believe.page_number || '-' }}</td>
<td>{{ getSystemCodeById(believe.game_system_id, believe.game_system) || '-' }}</td>
<td>
<button @click="startEdit(believe)">{{ $t('common.edit') }}</button>
</td>
</tr>
<tr v-else>
<td>{{ believe.id }}</td>
<td colspan="6">
<div class="edit-form">
<div class="edit-row">
<label>{{ $t('believe.name') }}</label>
<input v-model="editedItem.name" />
</div>
<div class="edit-row">
<label>{{ $t('believe.description') }}</label>
<input v-model="editedItem.beschreibung" />
</div>
<div class="edit-row">
<label>{{ $t('believe.source') }}</label>
<select v-model="editedItem.sourceCode">
<option value="">-</option>
<option v-for="source in sources" :key="source.id" :value="source.code">
{{ source.code }}
</option>
</select>
<label class="inline-label">{{ $t('believe.page') }}</label>
<input v-model.number="editedItem.page_number" type="number" min="0" />
</div>
<div class="edit-row">
<label>{{ $t('believe.system') }}</label>
<select v-model.number="selectedSystemId">
<option value="">-</option>
<option v-for="system in systemOptions" :key="system.id" :value="system.id">
{{ system.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>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
<style scoped>
/* Uses shared maintenance styles */
.error-box {
margin: 10px 0;
padding: 10px 12px;
background: #ffe3e3;
color: #8a1c1c;
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-row label {
font-weight: 600;
}
.edit-row input,
.edit-row select {
padding: 6px 10px;
border: 1px solid #dee2e6;
border-radius: 6px;
}
.edit-actions {
display: flex;
gap: 10px;
}
.inline-label {
margin-left: 10px;
}
</style>
<script>
import API from '../../utils/api'
import {
findSystemIdByCode,
getSourceCode,
getSystemCodeById,
loadGameSystems as fetchGameSystems,
buildSystemOptions,
} from '../../utils/maintenanceGameSystems'
export default {
name: "BelieveView",
props: {
mdata: {
type: Object,
required: false,
default: () => ({})
}
},
data() {
return {
believes: [],
sources: [],
editingId: null,
editedItem: null,
gameSystems: [],
selectedSystemId: null,
creatingNew: false,
newItem: null,
createSelectedSystemId: null,
isLoading: false,
isSaving: false,
error: '',
searchTerm: ''
}
},
async created() {
await this.loadGameSystems()
await this.loadBelieves()
},
computed: {
filteredBelieves() {
const term = this.searchTerm.trim().toLowerCase()
const filtered = term
? this.believes.filter(believe => {
const name = (believe.name || '').toLowerCase()
const desc = (believe.beschreibung || '').toLowerCase()
return name.includes(term) || desc.includes(term)
})
: this.believes
return [...filtered].sort((a, b) => (a.name || '').localeCompare(b.name || ''))
},
systemOptions() {
return buildSystemOptions(this.gameSystems)
}
},
methods: {
async loadGameSystems() {
try {
this.gameSystems = await fetchGameSystems()
} catch (err) {
console.error('Failed to load game systems:', err)
this.error = err.response?.data?.error || err.message
}
},
async loadBelieves() {
this.isLoading = true
this.error = ''
try {
const response = await API.get('/api/maintenance/gsm-believes')
this.believes = response.data?.believes || []
this.sources = response.data?.sources || []
} catch (err) {
console.error('Failed to load believes:', err)
this.error = err.response?.data?.error || err.message
} finally {
this.isLoading = false
}
},
getSourceCode(sourceId) {
return getSourceCode(this.sources, sourceId)
},
getSystemCodeById(systemId, fallback = '') {
return getSystemCodeById(this.gameSystems, systemId, fallback)
},
startEdit(believe) {
this.editingId = believe.id
this.editedItem = {
...believe,
sourceCode: this.getSourceCode(believe.source_id)
}
this.selectedSystemId = believe.game_system_id ?? this.findSystemIdByCode(believe.game_system)
},
cancelEdit() {
this.editingId = null
this.editedItem = null
this.selectedSystemId = null
},
findSystemIdByCode(code) {
return findSystemIdByCode(this.gameSystems, code)
},
startCreate() {
this.cancelEdit()
const defaultSystem = this.gameSystems.find(gs => gs.is_active) || this.gameSystems[0] || null
this.createSelectedSystemId = defaultSystem ? defaultSystem.id : null
this.newItem = {
name: '',
beschreibung: '',
sourceCode: '',
page_number: 0
}
this.creatingNew = true
},
cancelCreate() {
this.creatingNew = false
this.newItem = null
this.createSelectedSystemId = null
},
async saveEdit() {
if (!this.editedItem || !this.editingId) {
return
}
const trimmedName = (this.editedItem.name || '').trim()
if (!trimmedName) {
alert(this.$t('believe.nameRequired'))
return
}
const selectedSource = this.sources.find(src => src.code === this.editedItem.sourceCode)
const selectedSystem = this.gameSystems.find(gs => gs.id === this.selectedSystemId)
const payload = {
name: trimmedName,
beschreibung: this.editedItem.beschreibung || '',
source_id: selectedSource ? selectedSource.id : null,
page_number: this.editedItem.page_number || 0,
game_system_id: selectedSystem ? selectedSystem.id : null,
game_system: selectedSystem ? selectedSystem.code : '',
}
this.isSaving = true
try {
const response = await API.put(`/api/maintenance/gsm-believes/${this.editingId}`, payload)
const updated = response.data
const sourceCode = this.getSourceCode(updated.source_id)
const gameSystemCode = selectedSystem ? selectedSystem.code : updated.game_system
const gameSystemId = selectedSystem ? selectedSystem.id : (updated.game_system_id ?? null)
const idx = this.believes.findIndex(b => b.id === this.editingId)
if (idx !== -1) {
this.believes.splice(idx, 1, { ...updated, source_code: sourceCode, game_system: gameSystemCode, game_system_id: gameSystemId })
}
this.cancelEdit()
} catch (err) {
console.error('Failed to save believe:', err)
this.error = err.response?.data?.error || err.message
} finally {
this.isSaving = false
}
},
async saveCreate() {
if (!this.newItem) return
const trimmedName = (this.newItem.name || '').trim()
if (!trimmedName) {
alert(this.$t('believe.nameRequired'))
return
}
const selectedSource = this.sources.find(src => src.code === this.newItem.sourceCode)
const selectedSystem = this.gameSystems.find(gs => gs.id === this.createSelectedSystemId)
const payload = {
name: trimmedName,
beschreibung: this.newItem.beschreibung || '',
source_id: selectedSource ? selectedSource.id : null,
page_number: this.newItem.page_number || 0,
game_system_id: selectedSystem ? selectedSystem.id : null,
game_system: selectedSystem ? selectedSystem.code : '',
}
this.isSaving = true
try {
const response = await API.post('/api/maintenance/gsm-believes', payload)
const created = response.data
this.believes.push({
...created,
source_code: this.getSourceCode(created.source_id),
game_system: selectedSystem ? selectedSystem.code : created.game_system,
game_system_id: selectedSystem ? selectedSystem.id : (created.game_system_id ?? null)
})
this.cancelCreate()
} catch (err) {
console.error('Failed to create believe:', err)
this.error = err.response?.data?.error || err.message
} finally {
this.isSaving = false
}
}
}
}
</script>
@@ -1,532 +0,0 @@
<template>
<div class="header-section">
<h2>{{ $t('maintenance') }}</h2>
<!-- Add search input -->
<div class="search-box">
<input
type="text"
v-model="searchTerm"
:placeholder="`${$t('search')} ${$t('Equipment')}...`"
/>
<button @click="startCreate" class="btn-primary">{{ $t('newEntry') }}</button>
</div>
</div>
<div class="cd-view">
<div class="cd-list">
<!-- Filter Row -->
<div class="filter-row">
<div class="filter-item">
<label>{{ $t('equipment.personal_item') }}:</label>
<select v-model="filterPersonalItem">
<option value="">{{ $t('common.all') }}</option>
<option value="true">{{ $t('common.yes') }}</option>
<option value="false">{{ $t('common.no') }}</option>
</select>
</div>
<div class="filter-item">
<label>{{ $t('equipment.quelle') }}:</label>
<select v-model="filterQuelle">
<option value="">{{ $t('common.all') }}</option>
<option v-for="quelle in availableQuellen" :key="quelle" :value="quelle">{{ quelle }}</option>
</select>
</div>
<button @click="clearFilters" class="btn-clear-filters">{{ $t('common.clearFilters') }}</button>
</div>
<div class="tables-container">
<table class="cd-table">
<thead>
<tr>
<th class="cd-table-header">{{ $t('equipment.id') }}</th>
<th class="cd-table-header">
{{ $t('equipment.name') }}
<button @click="sortBy('name')">{{ sortField === 'name' ? (sortAsc ? '' : '') : '-' }}</button>
</th>
<th class="cd-table-header">{{ $t('equipment.gewicht') }}</th>
<th class="cd-table-header">{{ $t('equipment.wert') }}</th>
<th class="cd-table-header">{{ $t('equipment.description') }}</th>
<th class="cd-table-header">{{ $t('equipment.quelle') }}</th>
<th class="cd-table-header">{{ $t('equipment.personal_item') }}</th>
<th class="cd-table-header">{{ $t('equipment.system') }}</th>
<th class="cd-table-header"> </th>
</tr>
</thead>
<tbody>
<tr v-if="creatingNew">
<td>New</td>
<td colspan="8">
<div class="edit-form">
<div class="edit-row">
<div class="edit-field">
<label>{{ $t('equipment.name') }}:</label>
<input v-model="newItem.name" />
</div>
<div class="edit-field">
<label>{{ $t('equipment.gewicht') }}:</label>
<input v-model.number="newItem.gewicht" type="number" style="width:80px;" />
</div>
<div class="edit-field">
<label>{{ $t('equipment.wert') }}:</label>
<input v-model="newItem.wert" style="width:100px;" />
</div>
</div>
<div class="edit-row">
<div class="edit-field full-width">
<label>{{ $t('equipment.description') }}:</label>
<input v-model="newItem.beschreibung" />
</div>
</div>
<div class="edit-row">
<div class="edit-field">
<label>{{ $t('equipment.quelle') }}:</label>
<select v-model="newItem.sourceCode" style="width:100px;">
<option value="">-</option>
<option v-for="source in availableSources" :key="source.code" :value="source.code">
{{ source.code }}
</option>
</select>
</div>
<div class="edit-field">
<label>{{ $t('equipment.page') || 'Page' }}:</label>
<input v-model.number="newItem.page_number" type="number" style="width:60px;" />
</div>
<div class="edit-field">
<label>{{ $t('equipment.personal_item') }}:</label>
<input type="checkbox" v-model="newItem.personal_item" />
</div>
<div class="edit-field">
<label>{{ $t('equipment.system') }}:</label>
<select v-model.number="createSelectedSystemId" style="width:140px;">
<option value="">-</option>
<option v-for="system in systemOptions" :key="system.id" :value="system.id">
{{ system.label }}
</option>
</select>
</div>
</div>
<div class="edit-actions">
<button @click="saveCreate" class="btn-save">{{ $t('common.save') }}</button>
<button @click="cancelCreate" class="btn-cancel">{{ $t('common.cancel') }}</button>
</div>
</div>
</td>
</tr>
<template v-for="(dtaItem, index) in filteredAndSortedEquipments" :key="dtaItem.id">
<tr v-if="editingIndex !== index">
<td>{{ dtaItem.id || '' }}</td>
<!-- <td>{{ dtaItem.category|| '-' }}</td> -->
<td>{{ dtaItem.name || '-' }}</td>
<td>{{ dtaItem.gewicht || '-' }}</td>
<td>{{ dtaItem.wert || '-' }}</td>
<td>{{ dtaItem.beschreibung || '-' }}</td>
<td>{{ formatQuelle(dtaItem) }}</td>
<td><input type="checkbox" :checked="dtaItem.personal_item" disabled /></td>
<td>{{ getSystemCodeById(dtaItem.game_system_id, dtaItem.system || 'midgard') }}</td>
<td>
<button @click="startEdit(index)">{{ $t('common.edit') }}</button>
</td>
</tr>
<!-- Edit Mode -->
<tr v-else>
<td><input v-model="editedItem.id" style="width:20px;" disabled /></td>
<td colspan="8">
<!-- Expanded edit form -->
<div class="edit-form">
<div class="edit-row">
<div class="edit-field">
<label>{{ $t('equipment.name') }}:</label>
<input v-model="editedItem.name" />
</div>
<div class="edit-field">
<label>{{ $t('equipment.gewicht') }}:</label>
<input v-model.number="editedItem.gewicht" type="number" style="width:80px;" />
</div>
<div class="edit-field">
<label>{{ $t('equipment.wert') }}:</label>
<input v-model="editedItem.wert" style="width:100px;" />
</div>
</div>
<div class="edit-row">
<div class="edit-field full-width">
<label>{{ $t('equipment.description') }}:</label>
<input v-model="editedItem.beschreibung" />
</div>
</div>
<div class="edit-row">
<div class="edit-field">
<label>{{ $t('equipment.quelle') }}:</label>
<select v-model="editedItem.sourceCode" style="width:100px;">
<option value="">-</option>
<option v-for="source in availableSources" :key="source.code" :value="source.code">
{{ source.code }}
</option>
</select>
</div>
<div class="edit-field">
<label>{{ $t('equipment.page') || 'Page' }}:</label>
<input v-model.number="editedItem.page_number" type="number" style="width:60px;" />
</div>
<div class="edit-field">
<label>{{ $t('equipment.personal_item') }}:</label>
<input type="checkbox" v-model="editedItem.personal_item" />
</div>
<div class="edit-field">
<label>{{ $t('equipment.system') }}:</label>
<select v-model.number="selectedSystemId" style="width:140px;">
<option value="">-</option>
<option v-for="system in systemOptions" :key="system.id" :value="system.id">
{{ system.label }}
</option>
</select>
</div>
</div>
<div class="edit-actions">
<button @click="saveEdit(index)" class="btn-save">{{ $t('common.save') }}</button>
<button @click="cancelEdit" class="btn-cancel">{{ $t('common.cancel') }}</button>
</div>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div> <!--- end cd-list-->
</div> <!--- end character -datasheet-->
</template>
<!-- <style scoped> -->
<style>
.header-section {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.3rem;
height: fit-content;
padding: 0.5rem;
}
.search-box {
margin-bottom: 1rem;
}
.search-box input {
padding: 0.2rem;
width: 200px;
border: 1px solid #ddd;
border-radius: 4px;
}
.tables-container {
display: flex;
gap: 1rem;
width: 100%;
}
.table-wrapper-left {
flex: 6;
min-width: 0; /* Prevent table from overflowing */
}
.table-wrapper-right {
flex: 4;
min-width: 0; /* Prevent table from overflowing */
}
.cd-table {
width: 100%;
}
.cd-table-header {
background-color: #1da766;
font-weight: bold;
}
</style>
<script>
import API from '../../utils/api'
import {
findSystemIdByCode,
getSourceCode,
getSystemCodeById,
loadGameSystems as fetchGameSystems,
buildSystemOptions,
} from '../../utils/maintenanceGameSystems'
export default {
name: "EquipmentView",
props: {
mdata: {
type: Object,
required: true,
default: () => ({
equipments: [],
equipmentcategories: []
})
}
},
data() {
return {
searchTerm: '',
sortField: 'name',
sortAsc: true,
editingIndex: -1,
editedItem: null,
filterPersonalItem: '',
filterQuelle: '',
enhancedEquipment: [],
availableSources: [],
gameSystems: [],
selectedSystemId: null,
creatingNew: false,
newItem: null,
createSelectedSystemId: null
}
},
async created() {
await Promise.all([
this.loadGameSystems(),
this.loadEnhancedEquipment()
])
},
computed: {
availableQuellen() {
const quellen = new Set()
this.enhancedEquipment.forEach(equipment => {
if (equipment.source_id && this.availableSources.length > 0) {
const source = this.availableSources.find(s => s.id === equipment.source_id)
if (source) {
quellen.add(source.code)
}
}
})
return Array.from(quellen).sort()
},
filteredAndSortedEquipments() {
let filtered = [...this.enhancedEquipment]
// Apply search filter
if (this.searchTerm) {
const searchLower = this.searchTerm.toLowerCase()
filtered = filtered.filter(equipment =>
equipment.name?.toLowerCase().includes(searchLower)
)
}
// Apply personal_item filter
if (this.filterPersonalItem !== '') {
const personalItemValue = this.filterPersonalItem === 'true'
filtered = filtered.filter(equipment => equipment.personal_item === personalItemValue)
}
// Apply Quelle filter (only by source code, ignoring page number)
if (this.filterQuelle) {
filtered = filtered.filter(equipment => {
if (equipment.source_id && this.availableSources.length > 0) {
const source = this.availableSources.find(s => s.id === equipment.source_id)
return source && source.code === this.filterQuelle
}
return false
})
}
// Apply sorting
filtered.sort((a, b) => {
const aValue = (a[this.sortField] || '').toString().toLowerCase()
const bValue = (b[this.sortField] || '').toString().toLowerCase()
return this.sortAsc ? aValue.localeCompare(bValue) : bValue.localeCompare(aValue)
})
return filtered
},
sortedEquipments() {
return [...this.mdata.equipment].sort((a, b) => {
const aValue = (a[this.sortField] || '').toLowerCase();
const bValue = (b[this.sortField] || '').toLowerCase();
return this.sortAsc
? aValue.localeCompare(bValue)
: bValue.localeCompare(aValue);
});
},
systemOptions() {
return buildSystemOptions(this.gameSystems)
}
},
methods: {
async loadGameSystems() {
try {
this.gameSystems = await fetchGameSystems()
} catch (error) {
console.error('Failed to load game systems:', error)
}
},
async loadEnhancedEquipment() {
try {
const response = await API.get('/api/maintenance/equipment-enhanced')
this.enhancedEquipment = response.data.equipment || []
this.availableSources = response.data.sources || []
} catch (error) {
console.error('Failed to load enhanced equipment:', error)
}
},
startEdit(index) {
const equipment = this.filteredAndSortedEquipments[index]
this.editedItem = {
...equipment,
sourceCode: this.getSourceCode(equipment.source_id)
}
this.selectedSystemId = equipment.game_system_id ?? this.findSystemIdByCode(equipment.system)
this.editingIndex = index
},
async saveEdit(index) {
try {
// Find source ID from code
const source = this.availableSources.find(s => s.code === this.editedItem.sourceCode)
const selectedSystem = this.gameSystems.find(gs => gs.id === this.selectedSystemId)
const updateData = {
...this.editedItem,
source_id: source ? source.id : null,
page_number: this.editedItem.page_number || 0,
system: selectedSystem ? selectedSystem.code : (this.editedItem.system || ''),
game_system_id: selectedSystem ? selectedSystem.id : (this.editedItem.game_system_id ?? null)
}
const response = await API.put(
`/api/maintenance/equipment-enhanced/${this.editedItem.id}`,
updateData
)
// Update the equipment in the list using splice for proper reactivity
const equipmentIndex = this.enhancedEquipment.findIndex(e => e.id === this.editedItem.id)
if (equipmentIndex !== -1) {
this.enhancedEquipment.splice(equipmentIndex, 1, response.data)
}
this.editingIndex = -1
this.editedItem = null
this.selectedSystemId = null
} catch (error) {
console.error('Failed to save equipment:', error)
alert('Failed to save equipment: ' + (error.response?.data?.error || error.message))
}
},
cancelEdit() {
this.editingIndex = -1;
this.editedItem = null;
this.selectedSystemId = null;
},
startCreate() {
this.cancelEdit()
const defaultSystem = this.gameSystems.find(gs => gs.is_active) || this.gameSystems[0] || null
this.createSelectedSystemId = defaultSystem ? defaultSystem.id : null
this.newItem = {
name: '',
gewicht: 0,
wert: '',
beschreibung: '',
sourceCode: '',
page_number: 0,
personal_item: false,
system: defaultSystem ? defaultSystem.code : ''
}
this.creatingNew = true
},
cancelCreate() {
this.creatingNew = false
this.newItem = null
this.createSelectedSystemId = null
},
async saveCreate() {
if (!this.newItem) return
try {
const source = this.availableSources.find(s => s.code === this.newItem.sourceCode)
const selectedSystem = this.gameSystems.find(gs => gs.id === this.createSelectedSystemId)
const createData = {
name: this.newItem.name,
gewicht: this.newItem.gewicht ?? 0,
wert: this.newItem.wert || '',
beschreibung: this.newItem.beschreibung || '',
source_id: source ? source.id : null,
page_number: this.newItem.page_number || 0,
personal_item: !!this.newItem.personal_item,
system: selectedSystem ? selectedSystem.code : (this.newItem.system || ''),
game_system_id: selectedSystem ? selectedSystem.id : null
}
const response = await API.post(
'/api/maintenance/equipment-enhanced',
createData
)
this.enhancedEquipment.push(response.data)
this.cancelCreate()
} catch (error) {
console.error('Failed to create equipment:', error)
alert('Failed to create equipment: ' + (error.response?.data?.error || error.message))
}
},
findSystemIdByCode(code) {
return findSystemIdByCode(this.gameSystems, code)
},
sortBy(field) {
if (this.sortField === field) {
this.sortAsc = !this.sortAsc;
} else {
this.sortField = field;
this.sortAsc = true;
}
},
formatQuelle(equipment) {
if (equipment.source_id && this.availableSources.length > 0) {
const source = this.availableSources.find(s => s.id === equipment.source_id)
if (source) {
if (equipment.page_number) {
return `${source.code}:${equipment.page_number}`
} else {
// No page number - show code and quelle if available
const quelle = equipment.quelle ? ` (${equipment.quelle})` : ''
return `${source.code}${quelle}`
}
}
}
return equipment.quelle || '-'
},
getSourceCode(sourceId) {
return getSourceCode(this.availableSources, sourceId)
},
clearFilters() {
this.searchTerm = ''
this.filterPersonalItem = ''
this.filterQuelle = ''
},
getSystemCodeById(systemId, fallback = '') {
return getSystemCodeById(this.gameSystems, systemId, fallback)
},
async handleEquipmentUpdate({ index, equipment }) {
try {
const response = await API.put(
`/api/maintenance/equipment/${equipment.id}`, equipment,
{
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}` ,
'Content-Type': 'application/json'
}
}
)
if (response.status !== 200) throw new Error('Update failed');
const updatedSkill = response.data;
// Update the equipment in mdata
this.mdata.equipment = this.mdata.equipment.map(s =>
s.id === updatedSkill.id ? updatedSkill : s
);
} catch (error) {
console.error('Failed to update equipment:', error);
}
}
}
};
</script>
@@ -1,250 +0,0 @@
<template>
<div class="header-section">
<h2>{{ $t('maintenance') }} - {{ $t('gamesystem.title') }}</h2>
<div class="search-box">
<input v-model="searchTerm" type="text" :placeholder="$t('search')" />
<button class="btn-primary" @click="startCreate">{{ $t('newEntry') }}</button>
</div>
</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('gamesystem.id') }}</th>
<th class="cd-table-header">{{ $t('gamesystem.code') }}</th>
<th class="cd-table-header">{{ $t('gamesystem.name') }}</th>
<th class="cd-table-header">{{ $t('gamesystem.description') }}</th>
<th class="cd-table-header">{{ $t('gamesystem.active') }}</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('gamesystem.code') }}</label>
<input v-model="newItem.code" />
</div>
<div class="edit-row">
<label>{{ $t('gamesystem.name') }}</label>
<input v-model="newItem.name" />
</div>
<div class="edit-row">
<label>{{ $t('gamesystem.description') }}</label>
<input v-model="newItem.description" />
</div>
<div class="edit-row">
<label>{{ $t('gamesystem.active') }}</label>
<input type="checkbox" v-model="newItem.is_active" />
</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="gs in filteredSystems" :key="gs.id">
<tr v-if="editingId !== gs.id">
<td>{{ gs.id }}</td>
<td>{{ gs.code }}</td>
<td>{{ gs.name }}</td>
<td>{{ gs.description || '-' }}</td>
<td><input type="checkbox" :checked="gs.is_active" disabled /></td>
<td><button @click="startEdit(gs)">{{ $t('common.edit') }}</button></td>
</tr>
<tr v-else>
<td>{{ gs.id }}</td>
<td>{{ gs.code }}</td>
<td colspan="4">
<div class="edit-form">
<div class="edit-row">
<label>{{ $t('gamesystem.name') }}</label>
<input v-model="editedItem.name" />
</div>
<div class="edit-row">
<label>{{ $t('gamesystem.description') }}</label>
<input v-model="editedItem.description" />
</div>
<div class="edit-row">
<label>{{ $t('gamesystem.active') }}</label>
<input type="checkbox" v-model="editedItem.is_active" />
</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>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
<style scoped>
.error-box {
margin: 10px 0;
padding: 10px 12px;
background: #ffe3e3;
color: #8a1c1c;
border: 1px solid #f5c2c2;
border-radius: 6px;
}
.edit-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.edit-row {
display: flex;
gap: 10px;
align-items: center;
}
.edit-actions {
display: flex;
gap: 10px;
}
</style>
<script>
import API from '../../utils/api'
import { loadGameSystems as fetchGameSystems } from '../../utils/maintenanceGameSystems'
export default {
name: 'GameSystemView',
data() {
return {
systems: [],
editingId: null,
editedItem: null,
creatingNew: false,
newItem: null,
isLoading: false,
isSaving: false,
error: '',
searchTerm: '',
}
},
async created() {
await this.loadSystems()
},
computed: {
filteredSystems() {
const term = this.searchTerm.trim().toLowerCase()
const list = term
? this.systems.filter(gs =>
(gs.name || '').toLowerCase().includes(term) ||
(gs.code || '').toLowerCase().includes(term)
)
: this.systems
return [...list].sort((a, b) => (a.code || '').localeCompare(b.code || ''))
},
},
methods: {
async loadSystems() {
this.isLoading = true
this.error = ''
try {
this.systems = await fetchGameSystems()
} catch (err) {
console.error('Failed to load game systems:', err)
this.error = err.response?.data?.error || err.message
} finally {
this.isLoading = false
}
},
startEdit(gs) {
this.editingId = gs.id
this.editedItem = { ...gs }
},
cancelEdit() {
this.editingId = null
this.editedItem = null
},
startCreate() {
this.cancelEdit()
this.newItem = {
code: '',
name: '',
description: '',
is_active: true,
}
this.creatingNew = true
},
cancelCreate() {
this.creatingNew = false
this.newItem = null
},
async saveEdit() {
if (!this.editedItem) return
const payload = {
name: this.editedItem.name || '',
description: this.editedItem.description || '',
is_active: !!this.editedItem.is_active,
}
this.isSaving = true
try {
const resp = await API.put(`/api/maintenance/game-systems/${this.editingId}`, payload)
const idx = this.systems.findIndex(s => s.id === this.editingId)
if (idx !== -1) this.systems.splice(idx, 1, resp.data)
this.cancelEdit()
} catch (err) {
console.error('Failed to save game system:', err)
this.error = err.response?.data?.error || err.message
} finally {
this.isSaving = false
}
},
async saveCreate() {
if (!this.newItem) return
const code = (this.newItem.code || '').trim()
const name = (this.newItem.name || '').trim()
if (!code || !name) {
alert(this.$t('gamesystem.code') + ' / ' + this.$t('gamesystem.name'))
return
}
const payload = {
code,
name,
description: this.newItem.description || '',
is_active: !!this.newItem.is_active,
}
this.isSaving = true
try {
const resp = await API.post('/api/maintenance/game-systems', payload)
this.systems.push(resp.data)
this.cancelCreate()
} catch (err) {
console.error('Failed to create game system:', err)
this.error = err.response?.data?.error || err.message
} finally {
this.isSaving = false
}
},
},
}
</script>
@@ -1,440 +0,0 @@
<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,386 +0,0 @@
<template>
<div class="header-section">
<h2>{{ $t('maintenance') }} - {{ $t('litsource.title') }}</h2>
<div class="search-box">
<input v-model="searchTerm" type="text" :placeholder="$t('search')" />
</div>
<div class="search-box">
<select v-model.number="selectedSystemId" @change="handleGameSystemChange">
<option value="">{{ $t('gamesystem.title') }}</option>
<option v-for="system in systemOptions" :key="system.id" :value="system.id">
{{ system.label }}
</option>
</select>
</div>
<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('litsource.id') }}</th>
<th class="cd-table-header">{{ $t('litsource.code') }}</th>
<th class="cd-table-header">{{ $t('litsource.name') }}</th>
<th class="cd-table-header">{{ $t('litsource.fullName') }}</th>
<th class="cd-table-header">{{ $t('litsource.edition') }}</th>
<th class="cd-table-header">{{ $t('litsource.publisher') }}</th>
<th class="cd-table-header">{{ $t('litsource.year') }}</th>
<th class="cd-table-header">{{ $t('litsource.active') }}</th>
<th class="cd-table-header">{{ $t('litsource.core') }}</th>
<th class="cd-table-header"></th>
</tr>
</thead>
<tbody>
<tr v-if="isLoading">
<td colspan="10">{{ $t('common.loading') }}</td>
</tr>
<tr v-if="creatingNew">
<td>New</td>
<td colspan="9">
<div class="edit-form">
<div class="edit-row">
<label>{{ $t('litsource.code') }}</label>
<input v-model="newItem.code" />
<label class="inline-label">{{ $t('litsource.name') }}</label>
<input v-model="newItem.name" />
</div>
<div class="edit-row">
<label>{{ $t('litsource.fullName') }}</label>
<input v-model="newItem.full_name" />
</div>
<div class="edit-row">
<label>{{ $t('litsource.edition') }}</label>
<input v-model="newItem.edition" />
<label class="inline-label">{{ $t('litsource.publisher') }}</label>
<input v-model="newItem.publisher" />
<label class="inline-label">{{ $t('litsource.year') }}</label>
<input v-model.number="newItem.publish_year" type="number" />
</div>
<div class="edit-row">
<label>{{ $t('litsource.description') }}</label>
<input v-model="newItem.description" />
</div>
<div class="edit-row">
<label>{{ $t('litsource.active') }}</label>
<input type="checkbox" v-model="newItem.is_active" />
<label class="inline-label">{{ $t('litsource.core') }}</label>
<input type="checkbox" v-model="newItem.is_core" />
</div>
<div class="edit-row">
<label>{{ $t('gamesystem.title') }}</label>
<select v-model.number="createSelectedSystemId">
<option value="">-</option>
<option v-for="system in systemOptions" :key="system.id" :value="system.id">
{{ system.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="src in filteredSources" :key="src.id">
<tr v-if="editingId !== src.id">
<td>{{ src.id }}</td>
<td>{{ src.code }}</td>
<td>{{ src.name }}</td>
<td>{{ src.full_name }}</td>
<td>{{ src.edition }}</td>
<td>{{ src.publisher }}</td>
<td>{{ src.publish_year }}</td>
<td><input type="checkbox" :checked="src.is_active" disabled /></td>
<td><input type="checkbox" :checked="src.is_core" disabled /></td>
<td><button @click="startEdit(src)">{{ $t('common.edit') }}</button></td>
</tr>
<tr v-else>
<td>{{ src.id }}</td>
<td>{{ src.code }}</td>
<td colspan="8">
<div class="edit-form">
<div class="edit-row">
<label>{{ $t('litsource.name') }}</label>
<input v-model="editedItem.name" />
</div>
<div class="edit-row">
<label>{{ $t('litsource.fullName') }}</label>
<input v-model="editedItem.full_name" />
</div>
<div class="edit-row">
<label>{{ $t('litsource.edition') }}</label>
<input v-model="editedItem.edition" />
<label class="inline-label">{{ $t('litsource.publisher') }}</label>
<input v-model="editedItem.publisher" />
<label class="inline-label">{{ $t('litsource.year') }}</label>
<input v-model.number="editedItem.publish_year" type="number" />
</div>
<div class="edit-row">
<label>{{ $t('litsource.description') }}</label>
<input v-model="editedItem.description" />
</div>
<div class="edit-row">
<label>{{ $t('litsource.active') }}</label>
<input type="checkbox" v-model="editedItem.is_active" />
<label class="inline-label">{{ $t('litsource.core') }}</label>
<input type="checkbox" v-model="editedItem.is_core" />
</div>
<div class="edit-row">
<label>{{ $t('gamesystem.title') }}</label>
<select v-model.number="selectedSystemId">
<option value="">-</option>
<option v-for="system in systemOptions" :key="system.id" :value="system.id">
{{ system.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>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
<style scoped>
.error-box {
margin: 10px 0;
padding: 10px 12px;
background: #ffe3e3;
color: #8a1c1c;
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>
import API from '../../utils/api'
import {
buildGameSystemParams,
findSystemById,
findSystemIdByCode,
loadGameSystems as fetchGameSystems,
buildSystemOptions,
} from '../../utils/maintenanceGameSystems'
export default {
name: 'LitSourceView',
data() {
return {
gameSystems: [],
currentGameSystem: null,
selectedSystemId: null,
sources: [],
editingId: null,
editedItem: null,
creatingNew: false,
newItem: null,
createSelectedSystemId: null,
isLoading: false,
isSaving: false,
error: '',
searchTerm: '',
}
},
async created() {
await this.initialize()
},
computed: {
filteredSources() {
const term = this.searchTerm.trim().toLowerCase()
const list = term
? this.sources.filter(src =>
(src.name || '').toLowerCase().includes(term) ||
(src.code || '').toLowerCase().includes(term)
)
: this.sources
return [...list].sort((a, b) => (a.code || '').localeCompare(b.code || ''))
},
systemOptions() {
return buildSystemOptions(this.gameSystems)
},
},
methods: {
async initialize() {
this.error = ''
await this.loadGameSystems()
if (this.currentGameSystem) {
await this.loadSources()
}
},
async loadGameSystems() {
try {
const systems = await fetchGameSystems()
this.gameSystems = systems
const active = systems.find(s => s.is_active)
this.currentGameSystem = active || systems[0] || null
this.selectedSystemId = this.currentGameSystem ? this.currentGameSystem.id : null
} catch (err) {
console.error('Failed to load game systems:', err)
this.error = err.response?.data?.error || err.message
}
},
async loadSources() {
this.isLoading = true
this.error = ''
try {
const params = buildGameSystemParams(this.currentGameSystem)
const resp = await API.get('/api/maintenance/gsm-lit-sources', { params })
this.sources = resp.data?.sources || []
} catch (err) {
console.error('Failed to load sources:', err)
this.error = err.response?.data?.error || err.message
} finally {
this.isLoading = false
}
},
startEdit(src) {
this.editingId = src.id
this.editedItem = { ...src }
this.selectedSystemId = src.game_system_id
|| this.findSystemIdByCode(src.game_system)
|| this.currentGameSystem?.id
|| null
},
cancelEdit() {
this.editingId = null
this.editedItem = null
this.selectedSystemId = this.currentGameSystem ? this.currentGameSystem.id : null
},
handleGameSystemChange() {
const target = this.findSystemById(this.selectedSystemId)
this.currentGameSystem = target || this.currentGameSystem
if (this.currentGameSystem) {
this.loadSources()
}
},
startCreate() {
this.cancelEdit()
const defaultSystem = this.currentGameSystem || this.gameSystems.find(s => s.is_active) || this.gameSystems[0] || null
this.createSelectedSystemId = defaultSystem ? defaultSystem.id : null
this.newItem = {
code: '',
name: '',
full_name: '',
edition: '',
publisher: '',
publish_year: 0,
description: '',
is_active: true,
is_core: false,
}
this.creatingNew = true
},
cancelCreate() {
this.creatingNew = false
this.newItem = null
this.createSelectedSystemId = this.currentGameSystem ? this.currentGameSystem.id : null
},
findSystemById(id) {
return findSystemById(this.gameSystems, id)
},
findSystemIdByCode(code) {
return findSystemIdByCode(this.gameSystems, code)
},
async saveEdit() {
if (!this.editedItem) return
const targetSystem = this.findSystemById(this.selectedSystemId) || this.currentGameSystem
const payload = {
name: this.editedItem.name || '',
full_name: this.editedItem.full_name || '',
edition: this.editedItem.edition || '',
publisher: this.editedItem.publisher || '',
publish_year: this.editedItem.publish_year || 0,
description: this.editedItem.description || '',
is_active: !!this.editedItem.is_active,
is_core: !!this.editedItem.is_core,
game_system_id: targetSystem ? targetSystem.id : null,
game_system: targetSystem ? targetSystem.code : '',
}
this.isSaving = true
try {
const params = buildGameSystemParams(targetSystem)
const resp = await API.put(`/api/maintenance/gsm-lit-sources/${this.editingId}`, payload, { params })
const idx = this.sources.findIndex(s => s.id === this.editingId)
if (idx !== -1) this.sources.splice(idx, 1, resp.data)
this.cancelEdit()
} catch (err) {
console.error('Failed to save source:', err)
this.error = err.response?.data?.error || err.message
} finally {
this.isSaving = false
}
},
async saveCreate() {
if (!this.newItem) return
const targetSystem = this.findSystemById(this.createSelectedSystemId) || this.currentGameSystem
const payload = {
code: this.newItem.code || '',
name: this.newItem.name || '',
full_name: this.newItem.full_name || '',
edition: this.newItem.edition || '',
publisher: this.newItem.publisher || '',
publish_year: this.newItem.publish_year || 0,
description: this.newItem.description || '',
is_active: !!this.newItem.is_active,
is_core: !!this.newItem.is_core,
game_system_id: targetSystem ? targetSystem.id : null,
game_system: targetSystem ? targetSystem.code : '',
}
this.isSaving = true
try {
const params = buildGameSystemParams(targetSystem)
const resp = await API.post('/api/maintenance/gsm-lit-sources', payload, { params })
this.sources.push(resp.data)
this.cancelCreate()
} catch (err) {
console.error('Failed to create source:', err)
this.error = err.response?.data?.error || err.message
} finally {
this.isSaving = false
}
},
},
}
</script>
@@ -1,379 +0,0 @@
<template>
<div class="header-section">
<h2>{{ $t('maintenance') }} - {{ $t('misc.title') }}</h2>
<div class="search-box">
<input v-model="searchTerm" type="text" :placeholder="$t('search')" />
<select v-model="filterKey">
<option value="">{{ $t('common.all') }}</option>
<option v-for="key in keyOptions" :key="key" :value="key">{{ key }}</option>
</select>
<button class="btn-primary" @click="startCreate">{{ $t('newEntry') }}</button>
</div>
</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('misc.id') }}</th>
<th class="cd-table-header">{{ $t('misc.key') }}</th>
<th class="cd-table-header">{{ $t('misc.value') }}</th>
<th class="cd-table-header">{{ $t('misc.source') }}</th>
<th class="cd-table-header">{{ $t('misc.page') }}</th>
<th class="cd-table-header">{{ $t('misc.system') }}</th>
<th class="cd-table-header"></th>
</tr>
</thead>
<tbody>
<tr v-if="isLoading">
<td colspan="7">{{ $t('common.loading') }}</td>
</tr>
<tr v-if="creatingNew">
<td>New</td>
<td colspan="6">
<div class="edit-form">
<div class="edit-row">
<label>{{ $t('misc.key') }}</label>
<input v-model="newItem.key" list="misc-key-options" />
<datalist id="misc-key-options">
<option v-for="key in keyOptions" :key="key" :value="key" />
</datalist>
<label class="inline-label">{{ $t('misc.value') }}</label>
<input v-model="newItem.value" />
</div>
<div class="edit-row">
<label>{{ $t('misc.source') }}</label>
<select v-model.number="newItem.source_id">
<option :value="null">-</option>
<option v-for="src in sourceOptions" :key="src.id" :value="src.id">
{{ src.label }}
</option>
</select>
<label class="inline-label">{{ $t('misc.page') }}</label>
<input v-model.number="newItem.page_number" type="number" min="0" />
</div>
<div class="edit-row">
<label>{{ $t('misc.system') }}</label>
<select v-model.number="createSelectedSystemId">
<option v-for="sys in systemOptions" :key="sys.id" :value="sys.id">{{ sys.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="item in filteredItems" :key="item.id">
<tr v-if="editingId !== item.id">
<td>{{ item.id }}</td>
<td>{{ item.key }}</td>
<td>{{ item.value }}</td>
<td>{{ sourceCodeFor(item.source_id) }}</td>
<td>{{ item.page_number || '-' }}</td>
<td>{{ systemCodeFor(item.game_system_id, item.game_system) || '-' }}</td>
<td><button @click="startEdit(item)">{{ $t('common.edit') }}</button></td>
</tr>
<tr v-else>
<td>{{ item.id }}</td>
<td colspan="6">
<div class="edit-form">
<div class="edit-row">
<label>{{ $t('misc.key') }}</label>
<select v-model="editedItem.key">
<option :value="''">-</option>
<option v-for="key in keyOptionsWithCurrent" :key="key" :value="key">{{ key }}</option>
</select>
<label class="inline-label">{{ $t('misc.value') }}</label>
<input v-model="editedItem.value" />
</div>
<div class="edit-row">
<label>{{ $t('misc.source') }}</label>
<select v-model.number="editedItem.source_id">
<option :value="null">-</option>
<option v-for="src in sourceOptions" :key="src.id" :value="src.id">
{{ src.label }}
</option>
</select>
<label class="inline-label">{{ $t('misc.page') }}</label>
<input v-model.number="editedItem.page_number" type="number" min="0" />
</div>
<div class="edit-row">
<label>{{ $t('misc.system') }}</label>
<select v-model.number="selectedSystemId">
<option v-for="sys in systemOptions" :key="sys.id" :value="sys.id">{{ sys.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>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
<style scoped>
.error-box {
margin: 10px 0;
padding: 10px 12px;
background: #ffe3e3;
color: #8a1c1c;
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>
import API from '../../utils/api'
import {
buildGameSystemParams,
findSystemById,
loadGameSystems as fetchGameSystems,
systemCodeFor as resolveSystemCode,
buildSystemOptions,
} from '../../utils/maintenanceGameSystems'
export default {
name: 'MiscLookupView',
data() {
return {
items: [],
gameSystems: [],
currentGameSystem: null,
sources: [],
editingId: null,
editedItem: null,
selectedSystemId: null,
creatingNew: false,
newItem: null,
createSelectedSystemId: null,
isLoading: false,
isSaving: false,
error: '',
searchTerm: '',
filterKey: '',
}
},
async created() {
await this.initialize()
},
computed: {
filteredItems() {
const term = this.searchTerm.trim().toLowerCase()
let list = term
? this.items.filter(it =>
(it.key || '').toLowerCase().includes(term) ||
(it.value || '').toLowerCase().includes(term)
)
: this.items
if (this.filterKey) {
list = list.filter(it => it.key === this.filterKey)
}
return [...list].sort((a, b) => (a.key || '').localeCompare(b.key || ''))
},
keyOptions() {
const set = new Set()
this.items.forEach(it => {
if (it.key) set.add(it.key)
})
return Array.from(set.values()).sort()
},
keyOptionsWithCurrent() {
const set = new Set(this.keyOptions)
if (this.editedItem?.key) set.add(String(this.editedItem.key))
return Array.from(set.values()).sort()
},
systemOptions() {
const labelBuilder = system => {
const code = system.code || ''
const name = system.name || ''
if (code && name) return `${code} - ${name}`.trim()
return code || name || String(system.id ?? '')
}
return buildSystemOptions(this.gameSystems, labelBuilder)
},
sourceMap() {
const map = new Map()
this.sources.forEach(src => {
map.set(src.id, src.code || src.name || src.id)
})
return map
},
sourceOptions() {
return this.sources.map(src => ({
id: src.id,
label: src.code ? `${src.code} - ${src.name || ''}`.trim() : src.name || src.id,
}))
},
},
methods: {
async initialize() {
this.error = ''
await this.loadGameSystems()
if (!this.currentGameSystem) return
await this.loadSources()
await this.loadItems()
},
startCreate() {
this.cancelEdit()
const defaultSystem = this.currentGameSystem || this.gameSystems.find(gs => gs.is_active) || this.gameSystems[0] || null
this.createSelectedSystemId = defaultSystem ? defaultSystem.id : null
this.newItem = {
key: '',
value: '',
source_id: null,
page_number: 0,
}
this.creatingNew = true
},
cancelCreate() {
this.creatingNew = false
this.newItem = null
this.createSelectedSystemId = null
},
async loadGameSystems() {
try {
const systems = await fetchGameSystems()
this.gameSystems = systems
const active = systems.find(s => s.is_active)
this.currentGameSystem = active || systems[0] || null
} catch (err) {
console.error('Failed to load game systems:', err)
this.error = err.response?.data?.error || err.message
}
},
async loadSources() {
try {
const params = buildGameSystemParams(this.currentGameSystem)
const resp = await API.get('/api/maintenance/gsm-lit-sources', { params })
this.sources = resp.data?.sources || []
} catch (err) {
console.error('Failed to load sources:', err)
this.error = err.response?.data?.error || err.message
}
},
async loadItems() {
this.isLoading = true
this.error = ''
try {
const params = buildGameSystemParams(this.currentGameSystem)
const resp = await API.get('/api/maintenance/gsm-misc', { params })
this.items = resp.data?.misc || []
} catch (err) {
console.error('Failed to load misc:', err)
this.error = err.response?.data?.error || err.message
} finally {
this.isLoading = false
}
},
buildParamsForSystemId(systemId) {
const sys = findSystemById(this.gameSystems, systemId) || this.currentGameSystem
if (!sys) return {}
return buildGameSystemParams(sys)
},
sourceCodeFor(id) {
if (!id) return '-'
const code = this.sourceMap.get(id)
return code || id
},
systemCodeFor(systemId, fallback = '') {
return resolveSystemCode(this.gameSystems, systemId, fallback)
},
startEdit(item) {
this.editingId = item.id
this.editedItem = { ...item }
this.selectedSystemId = item.game_system_id ?? this.currentGameSystem?.id ?? null
},
cancelEdit() {
this.editingId = null
this.editedItem = null
this.selectedSystemId = null
},
async saveEdit() {
if (!this.editedItem) return
const payload = {
key: this.editedItem.key || '',
value: this.editedItem.value || '',
source_id: this.editedItem.source_id || null,
page_number: this.editedItem.page_number || 0,
}
this.isSaving = true
try {
const params = this.buildParamsForSystemId(this.selectedSystemId)
const resp = await API.put(`/api/maintenance/gsm-misc/${this.editingId}`, payload, { params })
const idx = this.items.findIndex(i => i.id === this.editingId)
if (idx !== -1) this.items.splice(idx, 1, resp.data)
this.cancelEdit()
} catch (err) {
console.error('Failed to save misc:', err)
this.error = err.response?.data?.error || err.message
} finally {
this.isSaving = false
}
},
async saveCreate() {
if (!this.newItem) return
const payload = {
key: this.newItem.key || '',
value: this.newItem.value || '',
source_id: this.newItem.source_id || null,
page_number: this.newItem.page_number || 0,
}
this.isSaving = true
try {
const params = this.buildParamsForSystemId(this.createSelectedSystemId)
const resp = await API.post('/api/maintenance/gsm-misc', payload, { params })
this.items.push(resp.data)
this.cancelCreate()
} catch (err) {
console.error('Failed to create misc entry:', err)
this.error = err.response?.data?.error || err.message
} finally {
this.isSaving = false
}
},
},
}
</script>
@@ -1,442 +0,0 @@
<template>
<div class="header-section">
<h2>{{ $t('maintenance') }} - {{ $t('skillimprovement.title') }}</h2>
</div>
<div v-if="error" class="error-box">{{ error }}</div>
<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>
</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>
<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>
</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;
background: #ffe3e3;
color: #8a1c1c;
border: 1px solid #f5c2c2;
border-radius: 6px;
}
</style>
<script>
import API from '../../utils/api'
export default {
name: 'SkillImprovementCostView',
data() {
return {
categories: [],
difficulties: [],
skillCatDiffs: [],
improvementCosts: [],
selectedCategoryId: null,
isLoading: false,
isSaving: false,
error: '',
editingCell: null,
editValue: 0,
editingLernenKey: null,
editLernenValue: 0,
}
},
computed: {
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)
)
},
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: [],
})
}
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: {
cellKey(diffId, level) {
return `${diffId}:${level}`
},
async loadAll() {
this.isLoading = true
this.error = ''
try {
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 skill improvement data:', err)
this.error = err.response?.data?.error || err.message
} finally {
this.isLoading = false
}
},
findImprovementCost(diffId, level) {
return this.categoryImprovementCosts.find(
c => c.skillDifficultyId === diffId && c.current_level === level
)
},
displayTE(diffId, level) {
const entry = this.findImprovementCost(diffId, level)
if (!entry) return '-'
return entry.te_required === 0 ? '-' : entry.te_required
},
startEditTE(diffId, level) {
const entry = this.findImprovementCost(diffId, level)
if (!entry) return
this.cancelEdit()
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()
}
})
},
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/${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 TE cost:', err)
this.error = err.response?.data?.error || err.message
} finally {
this.isSaving = false
}
},
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 {
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 save learn cost:', err)
this.error = err.response?.data?.error || err.message
} finally {
this.isSaving = false
}
},
},
}
</script>
@@ -1,744 +0,0 @@
<template>
<div class="header-section">
<h2>{{ $t('maintenance') }}</h2>
<div class="search-box">
<input
type="text"
v-model="searchTerm"
:placeholder="`${$t('search')} ${$t('Skill')}...`"
/>
<button @click="startCreate" class="btn-primary">{{ $t('newSkill') }}</button>
</div>
</div>
<div class="cd-view">
<div class="cd-list">
<!-- Filter Row -->
<div class="filter-row">
<div class="filter-item">
<label>{{ $t('skill.category') }}:</label>
<select v-model="filterCategory">
<option value="">{{ $t('common.all') }}</option>
<option v-for="cat in availableCategories" :key="cat" :value="cat">{{ cat }}</option>
</select>
</div>
<div class="filter-item">
<label>{{ $t('skill.difficulty') }}:</label>
<select v-model="filterDifficulty">
<option value="">{{ $t('common.all') }}</option>
<option v-for="diff in availableDifficulties" :key="diff" :value="diff">{{ diff }}</option>
</select>
</div>
<div class="filter-item">
<label>{{ $t('skill.improvable') }}:</label>
<select v-model="filterImprovable">
<option value="">{{ $t('common.all') }}</option>
<option value="true">{{ $t('common.yes') }}</option>
<option value="false">{{ $t('common.no') }}</option>
</select>
</div>
<div class="filter-item">
<label>{{ $t('skill.innateskill') }}:</label>
<select v-model="filterInnateskill">
<option value="">{{ $t('common.all') }}</option>
<option value="true">{{ $t('common.yes') }}</option>
<option value="false">{{ $t('common.no') }}</option>
</select>
</div>
<div class="filter-item">
<label>{{ $t('skill.bonusskill') }}:</label>
<select v-model="filterBonuseigenschaft">
<option value="">{{ $t('common.all') }}</option>
<option v-for="bonus in availableBonuseigenschaften" :key="bonus" :value="bonus">{{ bonus }}</option>
</select>
</div>
<button @click="clearFilters" class="btn-clear-filters">{{ $t('common.clearFilters') }}</button>
</div>
<div class="tables-container">
<table class="cd-table">
<thead>
<tr>
<th class="cd-table-header">{{ $t('skill.id') }}</th>
<th class="cd-table-header">
{{ $t('skill.category') }}
<button @click="sortBy('category')">{{ sortField === 'category' ? (sortAsc ? '' : '') : '-' }}</button>
</th>
<th class="cd-table-header">
{{ $t('skill.name') }}
<button @click="sortBy('name')">{{ sortField === 'name' ? (sortAsc ? '' : '') : '-' }}</button>
</th>
<th class="cd-table-header">{{ $t('skill.difficulty') }}</th>
<th class="cd-table-header">{{ $t('skill.initialwert') }}</th>
<th class="cd-table-header">{{ $t('skill.basiswert') }}</th>
<th class="cd-table-header">{{ $t('skill.improvable') }}</th>
<th class="cd-table-header">{{ $t('skill.innateskill') }}</th>
<th class="cd-table-header">{{ $t('skill.description') }}</th>
<th class="cd-table-header">{{ $t('skill.bonusskill') }}</th>
<th class="cd-table-header">{{ $t('skill.quelle') }}&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</th>
<th class="cd-table-header">{{ $t('skill.system') }}</th>
<th class="cd-table-header"> </th>
</tr>
</thead>
<tbody>
<!-- Create New Skill Row -->
<tr v-if="creatingNew">
<td>New</td>
<td colspan="11">
<!-- Create form -->
<div class="edit-form">
<div class="edit-row">
<div class="edit-field">
<label>{{ $t('skill.name') }}:</label>
<input v-model="editedItem.name" />
</div>
<div class="edit-field">
<label>{{ $t('skill.initialwert') }}:</label>
<input v-model.number="editedItem.initialwert" type="number" style="width:60px;" />
</div>
<div class="edit-field">
<label>{{ $t('skill.basiswert') }}:</label>
<input v-model.number="editedItem.basiswert" type="number" style="width:60px;" />
</div>
<div class="edit-field">
<label>{{ $t('skill.bonusskill') }}:</label>
<select v-model="editedItem.bonuseigenschaft" style="width:80px;">
<option value="">-</option>
<option value="St">St</option>
<option value="Gs">Gs</option>
<option value="Gw">Gw</option>
<option value="Ko">Ko</option>
<option value="In">In</option>
<option value="Zt">Zt</option>
<option value="Au">Au</option>
<option value="pA">pA</option>
<option value="Wk">Wk</option>
<option value="B">B</option>
</select>
</div>
</div>
<div class="edit-row">
<div class="edit-field">
<label>{{ $t('skill.improvable') }}:</label>
<input type="checkbox" v-model="editedItem.improvable" />
</div>
<div class="edit-field">
<label>{{ $t('skill.innateskill') }}:</label>
<input type="checkbox" v-model="editedItem.innateskill" />
</div>
</div>
<div class="edit-row">
<div class="edit-field full-width">
<label>{{ $t('skill.description') }}:</label>
<input v-model="editedItem.beschreibung" />
</div>
</div>
<div class="edit-row">
<div class="edit-field">
<label>{{ $t('skill.quelle') }}:</label>
<select v-model="editedItem.sourceCode" style="width:100px;">
<option v-for="source in availableSources" :key="source.code" :value="source.code">
{{ source.code }}
</option>
</select>
</div>
<div class="edit-field">
<label>{{ $t('skill.page') || 'Page' }}:</label>
<input v-model.number="editedItem.page_number" type="number" style="width:60px;" />
</div>
</div>
<div class="edit-row">
<div class="edit-field">
<label>{{ $t('skill.system') }}</label>
<select v-model.number="selectedSystemId" style="width:140px;">
<option value="">-</option>
<option v-for="system in systemOptions" :key="system.id" :value="system.id">
{{ system.label }}
</option>
</select>
</div>
</div>
<div class="edit-row">
<div class="edit-field">
<label>{{ $t('skill.system') }}</label>
<select v-model.number="createSelectedSystemId" style="width:140px;">
<option value="">-</option>
<option v-for="system in systemOptions" :key="system.id" :value="system.id">
{{ system.label }}
</option>
</select>
</div>
</div>
<div class="edit-row">
<div class="edit-field full-width">
<label>{{ $t('skill.categories') || 'Categories' }}:</label>
<div class="category-checkboxes">
<div v-for="category in mdata.categories" :key="category.id" class="category-checkbox">
<input
type="checkbox"
:value="category.id"
v-model="editedItem.selectedCategories"
@change="onCategoryToggle(category.id)"
/>
<label>{{ category.name }}</label>
</div>
</div>
</div>
</div>
<div class="edit-row">
<div class="edit-field full-width">
<label>{{ $t('skill.difficulty') || 'Difficulty' }}:</label>
<div class="difficulty-selects">
<div v-for="catId in editedItem.selectedCategories" :key="catId" class="difficulty-select">
<span>{{ getCategoryName(catId) }}:</span>
<select v-model="editedItem.categoryDifficulties[catId]" style="width:120px;">
<option v-for="diff in mdata.difficulties" :key="diff.id" :value="diff.id">
{{ diff.name }}
</option>
</select>
</div>
</div>
</div>
</div>
<div class="edit-actions">
<button @click="saveCreate" class="btn-save">{{ $t('common.save') }}</button>
<button @click="cancelCreate" class="btn-cancel">{{ $t('common.cancel') }}</button>
</div>
</div>
</td>
</tr>
<template v-for="(dtaItem, index) in filteredAndSortedSkills" :key="dtaItem.id">
<!-- Display Mode -->
<tr v-if="editingIndex !== index">
<td>{{ dtaItem.id || '' }}</td>
<td>{{ formatCategories(dtaItem.categories) }}</td>
<td>{{ dtaItem.name || '-' }}</td>
<td>{{ formatDifficulties(dtaItem.difficulties) }}</td>
<td>{{ dtaItem.initialwert || '0' }}</td>
<td>{{ dtaItem.basiswert || '0' }}</td>
<td><input type="checkbox" :checked="dtaItem.improvable" disabled /></td>
<td><input type="checkbox" :checked="dtaItem.innateskill" disabled /></td>
<td>{{ dtaItem.beschreibung || '-' }}</td>
<td>{{ dtaItem.bonuseigenschaft || '-' }}</td>
<td>{{ formatQuelle(dtaItem) }}</td>
<td>{{ getSystemCodeById(dtaItem.game_system_id, dtaItem.game_system || 'midgard') }}</td>
<td>
<button @click="startEdit(index)">{{ $t('common.edit') }}</button>
</td>
</tr>
<!-- Edit Mode -->
<tr v-else>
<td><input v-model="editedItem.id" style="width:20px;" disabled /></td>
<td colspan="11">
<!-- Expanded edit form -->
<div class="edit-form">
<div class="edit-row">
<div class="edit-field">
<label>{{ $t('skill.name') }}:</label>
<input v-model="editedItem.name" />
</div>
<div class="edit-field">
<label>{{ $t('skill.initialwert') }}:</label>
<input v-model.number="editedItem.initialwert" type="number" style="width:60px;" />
</div>
<div class="edit-field">
<label>{{ $t('skill.basiswert') }}:</label>
<input v-model.number="editedItem.basiswert" type="number" style="width:60px;" />
</div>
<div class="edit-field">
<label>{{ $t('skill.bonusskill') }}:</label>
<select v-model="editedItem.bonuseigenschaft" style="width:80px;">
<option value="">-</option>
<option value="St">St</option>
<option value="Gs">Gs</option>
<option value="Gw">Gw</option>
<option value="Ko">Ko</option>
<option value="In">In</option>
<option value="Zt">Zt</option>
<option value="Au">Au</option>
<option value="pA">pA</option>
<option value="Wk">Wk</option>
<option value="B">B</option>
</select>
</div>
</div>
<div class="edit-row">
<div class="edit-field">
<label>{{ $t('skill.improvable') }}:</label>
<input type="checkbox" v-model="editedItem.improvable" />
</div>
<div class="edit-field">
<label>{{ $t('skill.innateskill') }}:</label>
<input type="checkbox" v-model="editedItem.innateskill" />
</div>
</div>
<div class="edit-row">
<div class="edit-field full-width">
<label>{{ $t('skill.description') }}:</label>
<input v-model="editedItem.beschreibung" />
</div>
</div>
<div class="edit-row">
<div class="edit-field">
<label>{{ $t('skill.quelle') }}:</label>
<select v-model="editedItem.sourceCode" style="width:100px;">
<option v-for="source in availableSources" :key="source.code" :value="source.code">
{{ source.code }}
</option>
</select>
</div>
<div class="edit-field">
<label>{{ $t('skill.page') || 'Page' }}:</label>
<input v-model.number="editedItem.page_number" type="number" style="width:60px;" />
</div>
</div>
<div class="edit-row">
<div class="edit-field full-width">
<label>{{ $t('skill.categories') || 'Categories' }}:</label>
<div class="category-checkboxes">
<div v-for="category in mdata.categories" :key="category.id" class="category-checkbox">
<input
type="checkbox"
:value="category.id"
v-model="editedItem.selectedCategories"
@change="onCategoryToggle(category.id)"
/>
<label>{{ category.name }}</label>
</div>
</div>
</div>
</div>
<div class="edit-row">
<div class="edit-field full-width">
<label>{{ $t('skill.difficulty') || 'Difficulty' }}:</label>
<div class="difficulty-selects">
<div v-for="catId in editedItem.selectedCategories" :key="catId" class="difficulty-select">
<span>{{ getCategoryName(catId) }}:</span>
<select v-model="editedItem.categoryDifficulties[catId]" style="width:120px;">
<option v-for="diff in mdata.difficulties" :key="diff.id" :value="diff.id">
{{ diff.name }}
</option>
</select>
</div>
</div>
</div>
</div>
<div class="edit-actions">
<button @click="saveEdit(index)" class="btn-save">{{ $t('common.save') }}</button>
<button @click="cancelEdit" class="btn-cancel">{{ $t('common.cancel') }}</button>
</div>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</template>
<style>
/* All styles moved to main.css as per project conventions */
</style>
<script>
import API from '../../utils/api'
import {
findSystemIdByCode,
getSourceCode,
getSystemCodeById,
loadGameSystems as fetchGameSystems,
buildSystemOptions,
} from '../../utils/maintenanceGameSystems'
export default {
name: "SkillView",
props: {
mdata: {
type: Object,
required: true,
default: () => ({
skills: [],
categories: [],
difficulties: [],
sources: []
})
}
},
data() {
return {
searchTerm: '',
sortField: 'name',
sortAsc: true,
editingIndex: -1,
editedItem: null,
creatingNew: false,
filterCategory: '',
filterDifficulty: '',
filterImprovable: '',
filterInnateskill: '',
filterBonuseigenschaft: '',
enhancedSkills: [],
availableSources: [],
gameSystems: [],
selectedSystemId: null,
createSelectedSystemId: null
}
},
async created() {
await Promise.all([
this.loadGameSystems(),
this.loadEnhancedSkills()
])
},
computed: {
availableCategories() {
const categories = new Set()
this.enhancedSkills.forEach(skill => {
if (skill.categories) {
skill.categories.forEach(cat => categories.add(cat.category_name))
}
})
return Array.from(categories).sort()
},
availableDifficulties() {
const difficulties = new Set()
this.enhancedSkills.forEach(skill => {
if (skill.difficulties) {
skill.difficulties.forEach(diff => difficulties.add(diff))
}
})
return Array.from(difficulties).sort()
},
availableBonuseigenschaften() {
const bonuses = new Set()
this.enhancedSkills.forEach(skill => {
if (skill.bonuseigenschaft) {
bonuses.add(skill.bonuseigenschaft)
}
})
return Array.from(bonuses).sort()
},
filteredAndSortedSkills() {
let filtered = [...this.enhancedSkills]
// Apply search filter
if (this.searchTerm) {
const searchLower = this.searchTerm.toLowerCase()
filtered = filtered.filter(skill =>
skill.name?.toLowerCase().includes(searchLower) ||
this.formatCategories(skill.categories).toLowerCase().includes(searchLower)
)
}
// Apply category filter
if (this.filterCategory) {
filtered = filtered.filter(skill =>
skill.categories && skill.categories.some(cat => cat.category_name === this.filterCategory)
)
}
// Apply difficulty filter
if (this.filterDifficulty) {
filtered = filtered.filter(skill =>
skill.difficulties && skill.difficulties.includes(this.filterDifficulty)
)
}
// Apply improvable filter
if (this.filterImprovable !== '') {
const improvableValue = this.filterImprovable === 'true'
filtered = filtered.filter(skill => skill.improvable === improvableValue)
}
// Apply innateskill filter
if (this.filterInnateskill !== '') {
const innateskillValue = this.filterInnateskill === 'true'
filtered = filtered.filter(skill => skill.innateskill === innateskillValue)
}
// Apply bonuseigenschaft filter
if (this.filterBonuseigenschaft) {
filtered = filtered.filter(skill => skill.bonuseigenschaft === this.filterBonuseigenschaft)
}
// Apply sorting
filtered.sort((a, b) => {
let aValue, bValue
if (this.sortField === 'category') {
aValue = this.formatCategories(a.categories).toLowerCase()
bValue = this.formatCategories(b.categories).toLowerCase()
} else {
aValue = (a[this.sortField] || '').toString().toLowerCase()
bValue = (b[this.sortField] || '').toString().toLowerCase()
}
return this.sortAsc ? aValue.localeCompare(bValue) : bValue.localeCompare(aValue)
})
return filtered
},
systemOptions() {
return buildSystemOptions(this.gameSystems)
}
},
methods: {
async loadGameSystems() {
try {
this.gameSystems = await fetchGameSystems()
} catch (error) {
console.error('Failed to load game systems:', error)
}
},
async loadEnhancedSkills() {
try {
const response = await API.get('/api/maintenance/skills-enhanced')
this.enhancedSkills = response.data.skills || []
this.availableSources = response.data.sources || []
// Also update mdata for compatibility
if (response.data.categories) {
this.mdata.categories = response.data.categories
}
if (response.data.difficulties) {
this.mdata.difficulties = response.data.difficulties
}
} catch (error) {
console.error('Failed to load enhanced skills:', error)
}
},
formatCategories(categories) {
if (!categories || categories.length === 0) return '-'
return categories.map(cat => cat.category_name).join(', ')
},
formatDifficulties(difficulties) {
if (!difficulties || difficulties.length === 0) return '-'
return difficulties.join(', ')
},
formatQuelle(skill) {
if (skill.source_id && this.availableSources.length > 0) {
const source = this.availableSources.find(s => s.id === skill.source_id)
if (source) {
if (skill.page_number) {
return `${source.code}:${skill.page_number}`
} else {
// No page number - show code and quelle if available
const quelle = skill.quelle ? ` (${skill.quelle})` : ''
return `${source.code}${quelle}`
}
}
}
return skill.quelle || '-'
},
getCategoryName(categoryId) {
const category = this.mdata.categories.find(c => c.id === categoryId)
return category ? category.name : `ID:${categoryId}`
},
startEdit(index) {
const skill = this.filteredAndSortedSkills[index]
// Initialize edit object
this.editedItem = {
...skill,
selectedCategories: skill.categories ? skill.categories.map(cat => cat.category_id) : [],
categoryDifficulties: {},
sourceCode: this.getSourceCode(skill.source_id),
}
// Map category difficulties
if (skill.categories) {
skill.categories.forEach(cat => {
this.editedItem.categoryDifficulties[cat.category_id] = cat.difficulty_id
})
}
this.selectedSystemId = skill.game_system_id ?? this.findSystemIdByCode(skill.game_system)
this.editingIndex = index
},
getSourceCode(sourceId) {
return getSourceCode(this.availableSources, sourceId)
},
findSystemIdByCode(code) {
return findSystemIdByCode(this.gameSystems, code)
},
getSystemCodeById(systemId, fallback = '') {
return getSystemCodeById(this.gameSystems, systemId, fallback)
},
onCategoryToggle(categoryId) {
// If category was removed, also remove its difficulty setting
if (!this.editedItem.selectedCategories.includes(categoryId)) {
delete this.editedItem.categoryDifficulties[categoryId]
} else {
// Set a default difficulty if not already set
if (!this.editedItem.categoryDifficulties[categoryId] && this.mdata.difficulties.length > 0) {
// Find "normal" difficulty or use first one
const normalDiff = this.mdata.difficulties.find(d => d.name.toLowerCase() === 'normal')
this.editedItem.categoryDifficulties[categoryId] = normalDiff ? normalDiff.id : this.mdata.difficulties[0].id
}
}
},
async saveEdit(index) {
try {
// Find source ID from code
const source = this.availableSources.find(s => s.code === this.editedItem.sourceCode)
const selectedSystem = this.gameSystems.find(gs => gs.id === this.selectedSystemId)
// Build category_difficulties array
const categoryDifficulties = this.editedItem.selectedCategories.map(catId => ({
category_id: catId,
difficulty_id: this.editedItem.categoryDifficulties[catId]
}))
const updateData = {
id: this.editedItem.id,
name: this.editedItem.name,
beschreibung: this.editedItem.beschreibung,
game_system: selectedSystem ? selectedSystem.code : (this.editedItem.game_system || 'midgard'),
game_system_id: selectedSystem ? selectedSystem.id : (this.editedItem.game_system_id ?? null),
initialwert: this.editedItem.initialwert,
basiswert: this.editedItem.basiswert || 0,
bonuseigenschaft: this.editedItem.bonuseigenschaft,
improvable: this.editedItem.improvable,
innateskill: this.editedItem.innateskill,
source_id: source ? source.id : null,
page_number: this.editedItem.page_number || 0,
category_difficulties: categoryDifficulties
}
const response = await API.put(
`/api/maintenance/skills-enhanced/${this.editedItem.id}`,
updateData
)
// Update the skill in the list using splice for proper reactivity
const skillIndex = this.enhancedSkills.findIndex(s => s.id === this.editedItem.id)
if (skillIndex !== -1) {
this.enhancedSkills.splice(skillIndex, 1, response.data)
}
this.editingIndex = -1
this.editedItem = null
this.selectedSystemId = null
} catch (error) {
console.error('Failed to update skill:', error)
alert('Failed to update skill: ' + (error.response?.data?.error || error.message))
}
},
cancelEdit() {
this.editingIndex = -1
this.editedItem = null
},
sortBy(field) {
if (this.sortField === field) {
this.sortAsc = !this.sortAsc
} else {
this.sortField = field
this.sortAsc = true
}
},
clearFilters() {
this.searchTerm = ''
this.filterCategory = ''
this.filterDifficulty = ''
this.filterImprovable = ''
this.filterInnateskill = ''
this.filterBonuseigenschaft = ''
},
startCreate() {
// Initialize new skill object with defaults
const defaultSystem = this.gameSystems.find(gs => gs.is_active) || this.gameSystems[0] || null
this.createSelectedSystemId = defaultSystem ? defaultSystem.id : null
this.editedItem = {
name: '',
beschreibung: '',
game_system: defaultSystem ? defaultSystem.code : 'midgard',
game_system_id: defaultSystem ? defaultSystem.id : null,
initialwert: 5,
basiswert: 0,
bonuseigenschaft: '',
improvable: true,
innateskill: false,
sourceCode: this.availableSources.length > 0 ? this.availableSources[0].code : '',
page_number: 0,
selectedCategories: [],
categoryDifficulties: {}
}
this.creatingNew = true
},
async saveCreate() {
try {
// Validate required fields
if (!this.editedItem.name) {
alert('Skill name is required')
return
}
// Find source ID from code
const source = this.availableSources.find(s => s.code === this.editedItem.sourceCode)
const selectedSystem = this.gameSystems.find(gs => gs.id === this.createSelectedSystemId)
// Build category_difficulties array
const categoryDifficulties = this.editedItem.selectedCategories.map(catId => ({
category_id: catId,
difficulty_id: this.editedItem.categoryDifficulties[catId]
}))
const createData = {
name: this.editedItem.name,
beschreibung: this.editedItem.beschreibung,
game_system: selectedSystem ? selectedSystem.code : (this.editedItem.game_system || 'midgard'),
game_system_id: selectedSystem ? selectedSystem.id : (this.editedItem.game_system_id ?? null),
initialwert: this.editedItem.initialwert,
basiswert: this.editedItem.basiswert || 0,
bonuseigenschaft: this.editedItem.bonuseigenschaft,
improvable: this.editedItem.improvable,
innateskill: this.editedItem.innateskill,
source_id: source ? source.id : null,
page_number: this.editedItem.page_number || 0,
category_difficulties: categoryDifficulties
}
const response = await API.post(
'/api/maintenance/skills-enhanced',
createData
)
// Add the new skill to the list
this.enhancedSkills.push(response.data)
// Hide the create dialog
this.creatingNew = false
this.editedItem = null
this.createSelectedSystemId = null
} catch (error) {
console.error('Failed to create skill:', error)
alert('Failed to create skill: ' + (error.response?.data?.error || error.message))
// Don't close dialog on error so user can fix the issue
}
},
cancelCreate() {
this.creatingNew = false
this.editedItem = null
this.createSelectedSystemId = null
}
}
}
</script>
@@ -1,911 +0,0 @@
<template>
<div class="header-section">
<h2>{{ $t('maintenance') }}</h2>
<!-- Add search input -->
<div class="search-box">
<input
type="text"
v-model="searchTerm"
:placeholder="`${$t('search')} ${$t('Spell')}...`"
/>
<button @click="startCreate" class="btn-primary">{{ $t('newEntry') }}</button>
</div>
</div>
<!-- Import CSV Section -->
<div class="import-section">
<h3>{{ $t('spell.import') || 'Import Spells' }}</h3>
<div class="file-upload">
<input
ref="fileInput"
type="file"
accept=".csv"
@change="handleFileSelect"
style="display: none;"
/>
<button
@click="$refs.fileInput.click()"
:disabled="importing"
class="upload-btn"
>
{{ importing ? ($t('spell.importing') || 'Importing...') : ($t('spell.selectCsv') || 'Select CSV File') }}
</button>
<span v-if="selectedFile" class="file-name">{{ selectedFile.name }}</span>
</div>
<button
v-if="selectedFile && !importing"
@click="importSpells"
class="import-btn"
>
{{ $t('spell.import') || 'Import Spells' }}
</button>
<div v-if="importResult" class="import-result" :class="importResult.success ? 'success' : 'error'">
async saveCreate() {
if (!this.newItem) return
try {
const source = this.availableSources.find(s => s.code === this.newItem.sourceCode)
const selectedSystem = this.gameSystems.find(gs => gs.id === this.createSelectedSystemId)
const createData = {
...this.newItem,
source_id: source ? source.id : null,
page_number: this.newItem.page_number || 0,
system: selectedSystem ? selectedSystem.code : (this.newItem.system || ''),
game_system_id: selectedSystem ? selectedSystem.id : null
}
const response = await API.post(
'/api/maintenance/spells-enhanced',
createData
)
this.enhancedSpells.push(response.data)
this.cancelCreate()
} catch (error) {
console.error('Failed to create spell:', error)
alert('Failed to create spell: ' + (error.response?.data?.error || error.message))
}
},
{{ importResult.message }}
<span v-if="importResult.total_spells"> ({{ importResult.total_spells }} spells total)</span>
</div>
</div>
<div class="cd-view">
<div class="cd-list">
<!-- Filter Row -->
<div class="filter-row">
<div class="filter-item">
<label>{{ $t('spell.category') }}:</label>
<select v-model="filterCategory">
<option value="">{{ $t('common.all') }}</option>
<option v-for="cat in availableCategories" :key="cat" :value="cat">{{ cat }}</option>
</select>
</div>
<div class="filter-item">
<label>{{ $t('spell.level') }}:</label>
<select v-model="filterLevel">
<option value="">{{ $t('common.all') }}</option>
<option v-for="level in availableLevels" :key="level" :value="level">{{ level }}</option>
</select>
</div>
<div class="filter-item">
<label>{{ $t('spell.ursprung') }}:</label>
<select v-model="filterUrsprung">
<option value="">{{ $t('common.all') }}</option>
<option v-for="ursprung in availableUrsprungs" :key="ursprung" :value="ursprung">{{ ursprung }}</option>
</select>
</div>
<div class="filter-item">
<label>{{ $t('spell.reichweite') }}:</label>
<select v-model="filterReichweite">
<option value="">{{ $t('common.all') }}</option>
<option v-for="reichweite in availableReichweiten" :key="reichweite" :value="reichweite">{{ reichweite }}</option>
</select>
</div>
<div class="filter-item">
<label>{{ $t('spell.wirkungsziel') }}:</label>
<select v-model="filterWirkungsziel">
<option value="">{{ $t('common.all') }}</option>
<option v-for="wirkungsziel in availableWirkungsziele" :key="wirkungsziel" :value="wirkungsziel">{{ wirkungsziel }}</option>
</select>
</div>
<div class="filter-item">
<label>{{ $t('spell.quelle') }}:</label>
<select v-model="filterQuelle">
<option value="">{{ $t('common.all') }}</option>
<option v-for="quelle in availableQuellen" :key="quelle" :value="quelle">{{ quelle }}</option>
</select>
</div>
<button @click="clearFilters" class="btn-clear-filters">{{ $t('common.clearFilters') }}</button>
</div>
<div class="tables-container">
<table class="cd-table">
<thead>
<tr>
<th class="cd-table-header">{{ $t('spell.id') }}</th>
<th class="cd-table-header">
{{ $t('spell.category') }}
<button @click="sortBy('category')">{{ sortField === 'category' ? (sortAsc ? '' : '') : '-' }}</button>
</th>
<th class="cd-table-header">
{{ $t('spell.name') }}
<button @click="sortBy('name')">{{ sortField === 'name' ? (sortAsc ? '' : '') : '-' }}</button>
</th>
<th class="cd-table-header">{{ $t('spell.level') }}</th>
<th class="cd-table-header">{{ $t('spell.apverbrauch') }}</th>
<th class="cd-table-header">{{ $t('spell.zauberdauer') }}</th>
<th class="cd-table-header">{{ $t('spell.reichweite') }}</th>
<th class="cd-table-header">{{ $t('spell.wirkungsziel') }}</th>
<th class="cd-table-header">{{ $t('spell.wirkungsbereich') }}</th>
<th class="cd-table-header">{{ $t('spell.wirkungsdauer') }}</th>
<th class="cd-table-header">{{ $t('spell.ursprung') }}</th>
<th class="cd-table-header">{{ $t('spell.description') }}</th>
<th class="cd-table-header">{{ $t('spell.quelle') }}</th>
<th class="cd-table-header">{{ $t('spell.system') }}</th>
<th class="cd-table-header"> </th>
</tr>
</thead>
<tbody>
<tr v-if="creatingNew">
<td>New</td>
<td colspan="14">
<div class="edit-form">
<div class="edit-row">
<div class="edit-field">
<label>{{ $t('spell.name') }}:</label>
<input v-model="newItem.name" />
</div>
<div class="edit-field">
<label>{{ $t('spell.category') }}:</label>
<select v-model="newItem.category" style="width:120px;">
<option v-for="category in mdata['spellcategories']" :key="category" :value="category">
{{ category }}
</option>
</select>
</div>
<div class="edit-field">
<label>{{ $t('spell.level') }}:</label>
<input v-model.number="newItem.level" type="number" style="width:60px;" />
</div>
<div class="edit-field">
<label>{{ $t('spell.apverbrauch') }}:</label>
<input v-model="newItem.ap" style="width:60px;" />
</div>
</div>
<div class="edit-row">
<div class="edit-field">
<label>{{ $t('spell.zauberdauer') }}:</label>
<input v-model="newItem.zauberdauer" style="width:120px;" />
</div>
<div class="edit-field">
<label>{{ $t('spell.reichweite') }}:</label>
<input v-model="newItem.reichweite" style="width:120px;" />
</div>
<div class="edit-field">
<label>{{ $t('spell.wirkungsdauer') }}:</label>
<input v-model="newItem.wirkungsdauer" style="width:120px;" />
</div>
</div>
<div class="edit-row">
<div class="edit-field">
<label>{{ $t('spell.wirkungsziel') }}:</label>
<input v-model="newItem.wirkungsziel" style="width:150px;" />
</div>
<div class="edit-field">
<label>{{ $t('spell.wirkungsbereich') }}:</label>
<input v-model="newItem.wirkungsbereich" style="width:150px;" />
</div>
<div class="edit-field">
<label>{{ $t('spell.ursprung') }}:</label>
<input v-model="newItem.ursprung" style="width:120px;" />
</div>
</div>
<div class="edit-row">
<div class="edit-field full-width">
<label>{{ $t('spell.description') }}:</label>
<input v-model="newItem.beschreibung" />
</div>
</div>
<div class="edit-row">
<div class="edit-field">
<label>{{ $t('spell.quelle') }}:</label>
<select v-model="newItem.sourceCode" style="width:100px;">
<option value="">-</option>
<option v-for="source in availableSources" :key="source.code" :value="source.code">
{{ source.code }}
</option>
</select>
</div>
<div class="edit-field">
<label>{{ $t('spell.page') || 'Page' }}:</label>
<input v-model.number="newItem.page_number" type="number" style="width:60px;" />
</div>
<div class="edit-field">
<label>{{ $t('spell.system') }}:</label>
<select v-model.number="createSelectedSystemId" style="width:140px;">
<option value="">-</option>
<option v-for="system in systemOptions" :key="system.id" :value="system.id">
{{ system.label }}
</option>
</select>
</div>
</div>
<div class="edit-actions">
<button @click="saveCreate" class="btn-save">{{ $t('common.save') }}</button>
<button @click="cancelCreate" class="btn-cancel">{{ $t('common.cancel') }}</button>
</div>
</div>
</td>
</tr>
<template v-for="(dtaItem, index) in filteredAndSortedSpells" :key="dtaItem.id">
<tr v-if="editingIndex !== index">
<td>{{ dtaItem.id || '' }}</td>
<td>{{ dtaItem.category|| '-' }}</td>
<td>{{ dtaItem.name || '-' }}</td>
<td>{{ dtaItem.level || '0' }}</td>
<td>{{ dtaItem.ap || '0' }}</td>
<td>{{ dtaItem.zauberdauer || '-' }}</td>
<td>{{ dtaItem.reichweite || '0' }}</td>
<td>{{ dtaItem.wirkungsziel || '-' }}</td>
<td>{{ dtaItem.wirkungsbereich || '-' }}</td>
<td>{{ dtaItem.wirkungsdauer || '-' }}</td>
<td>{{ dtaItem.ursprung || '-' }}</td>
<td>{{ dtaItem.beschreibung || '-' }}</td>
<td>{{ formatQuelle(dtaItem) }}</td>
<td>{{ getSystemCodeById(dtaItem.game_system_id, dtaItem.system || 'midgard') }}</td>
<td>
<button @click="startEdit(index)">{{ $t('common.edit') }}</button>
</td>
</tr>
<!-- Edit Mode -->
<tr v-else>
<td><input v-model="editedItem.id" style="width:20px;" disabled /></td>
<td colspan="14">
<!-- Expanded edit form -->
<div class="edit-form">
<div class="edit-row">
<div class="edit-field">
<label>{{ $t('spell.name') }}:</label>
<input v-model="editedItem.name" />
</div>
<div class="edit-field">
<label>{{ $t('spell.category') }}:</label>
<select v-model="editedItem.category" style="width:120px;">
<option v-for="category in mdata['spellcategories']" :key="category" :value="category">
{{ category }}
</option>
</select>
</div>
<div class="edit-field">
<label>{{ $t('spell.level') }}:</label>
<input v-model.number="editedItem.level" type="number" style="width:60px;" />
</div>
<div class="edit-field">
<label>{{ $t('spell.apverbrauch') }}:</label>
<input v-model="editedItem.ap" style="width:60px;" />
</div>
</div>
<div class="edit-row">
<div class="edit-field">
<label>{{ $t('spell.zauberdauer') }}:</label>
<input v-model="editedItem.zauberdauer" style="width:120px;" />
</div>
<div class="edit-field">
<label>{{ $t('spell.reichweite') }}:</label>
<input v-model="editedItem.reichweite" style="width:120px;" />
</div>
<div class="edit-field">
<label>{{ $t('spell.wirkungsdauer') }}:</label>
<input v-model="editedItem.wirkungsdauer" style="width:120px;" />
</div>
</div>
<div class="edit-row">
<div class="edit-field">
<label>{{ $t('spell.wirkungsziel') }}:</label>
<input v-model="editedItem.wirkungsziel" style="width:150px;" />
</div>
<div class="edit-field">
<label>{{ $t('spell.wirkungsbereich') }}:</label>
<input v-model="editedItem.wirkungsbereich" style="width:150px;" />
</div>
<div class="edit-field">
<label>{{ $t('spell.ursprung') }}:</label>
<input v-model="editedItem.ursprung" style="width:120px;" />
</div>
</div>
<div class="edit-row">
<div class="edit-field full-width">
<label>{{ $t('spell.description') }}:</label>
<input v-model="editedItem.beschreibung" />
</div>
</div>
<div class="edit-row">
<div class="edit-field">
<label>{{ $t('spell.quelle') }}:</label>
<select v-model="editedItem.sourceCode" style="width:100px;">
<option value="">-</option>
<option v-for="source in availableSources" :key="source.code" :value="source.code">
{{ source.code }}
</option>
</select>
</div>
<div class="edit-field">
<label>{{ $t('spell.page') || 'Page' }}:</label>
<input v-model.number="editedItem.page_number" type="number" style="width:60px;" />
</div>
<div class="edit-field">
<label>{{ $t('spell.system') }}:</label>
<select v-model.number="selectedSystemId" style="width:140px;">
<option value="">-</option>
<option v-for="system in systemOptions" :key="system.id" :value="system.id">
{{ system.label }}
</option>
</select>
</div>
</div>
<div class="edit-actions">
<button @click="saveEdit(index)" class="btn-save">{{ $t('common.save') }}</button>
<button @click="cancelEdit" class="btn-cancel">{{ $t('common.cancel') }}</button>
</div>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div> <!--- end cd-list-->
</div> <!--- end character -datasheet-->
</template>
<!-- <style scoped> -->
<style>
.header-section {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
height: fit-content;
padding: 0.5rem;
border-bottom: 1px solid #ddd;
}
.search-box {
margin-left: auto;
}
.search-box input {
padding: 0.5rem;
width: 250px;
border: 1px solid #ddd;
border-radius: 4px;
}
.import-section {
margin-bottom: 1.5rem;
padding: 1.5rem;
border: 2px solid #1da766;
border-radius: 8px;
background-color: #f8fcfa;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.import-section h3 {
margin: 0 0 1rem 0;
color: #333;
}
.file-upload {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.upload-btn, .import-btn {
padding: 0.5rem 1rem;
border: 1px solid #ddd;
border-radius: 4px;
background-color: #1da766;
color: white;
cursor: pointer;
transition: background-color 0.2s;
}
.upload-btn:hover, .import-btn:hover {
background-color: #166d4a;
}
.upload-btn:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.file-name {
font-style: italic;
color: #666;
}
.import-result {
padding: 0.5rem;
border-radius: 4px;
margin-top: 1rem;
}
.import-result.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.import-result.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.tables-container {
display: flex;
gap: 1rem;
width: 100%;
}
.table-wrapper-left {
flex: 6;
min-width: 0; /* Prevent table from overflowing */
}
.table-wrapper-right {
flex: 4;
min-width: 0; /* Prevent table from overflowing */
}
.cd-table {
width: 100%;
}
.cd-table-header {
background-color: #1da766;
font-weight: bold;
}
</style>
<script>
import API from '../../utils/api'
import {
findSystemIdByCode,
getSourceCode,
getSystemCodeById,
loadGameSystems as fetchGameSystems,
buildSystemOptions,
} from '../../utils/maintenanceGameSystems'
export default {
name: "SpellView",
props: {
mdata: {
type: Object,
required: true,
default: () => ({
spells: [],
spellcategories: []
})
}
},
data() {
return {
searchTerm: '',
sortField: 'name',
sortAsc: true,
editingIndex: -1,
editedItem: null,
selectedFile: null,
importing: false,
importResult: null,
filterCategory: '',
filterLevel: '',
filterUrsprung: '',
filterReichweite: '',
filterWirkungsziel: '',
filterQuelle: '',
enhancedSpells: [],
availableSources: [],
gameSystems: [],
selectedSystemId: null,
creatingNew: false,
newItem: null,
createSelectedSystemId: null
}
},
async created() {
await Promise.all([
this.loadGameSystems(),
this.loadEnhancedSpells()
])
},
computed: {
availableCategories() {
const categories = new Set()
this.enhancedSpells.forEach(spell => {
if (spell.category) categories.add(spell.category)
})
return Array.from(categories).sort()
},
availableLevels() {
const levels = new Set()
this.enhancedSpells.forEach(spell => {
if (spell.level !== null && spell.level !== undefined) levels.add(spell.level)
})
return Array.from(levels).sort((a, b) => a - b)
},
availableUrsprungs() {
const ursprungs = new Set()
this.enhancedSpells.forEach(spell => {
if (spell.ursprung) ursprungs.add(spell.ursprung)
})
return Array.from(ursprungs).sort()
},
availableReichweiten() {
const reichweiten = new Set()
this.enhancedSpells.forEach(spell => {
if (spell.reichweite) reichweiten.add(spell.reichweite)
})
return Array.from(reichweiten).sort()
},
availableWirkungsziele() {
const wirkungsziele = new Set()
this.enhancedSpells.forEach(spell => {
if (spell.wirkungsziel) wirkungsziele.add(spell.wirkungsziel)
})
return Array.from(wirkungsziele).sort()
},
availableQuellen() {
const quellen = new Set()
this.enhancedSpells.forEach(spell => {
if (spell.source_id && this.availableSources.length > 0) {
const source = this.availableSources.find(s => s.id === spell.source_id)
if (source) {
quellen.add(source.code)
}
}
})
return Array.from(quellen).sort()
},
filteredAndSortedSpells() {
let filtered = [...this.enhancedSpells]
// Apply search filter
if (this.searchTerm) {
const searchLower = this.searchTerm.toLowerCase()
filtered = filtered.filter(spell =>
spell.name?.toLowerCase().includes(searchLower) ||
spell.category?.toLowerCase().includes(searchLower)
)
}
// Apply category filter
if (this.filterCategory) {
filtered = filtered.filter(spell => spell.category === this.filterCategory)
}
// Apply level filter
if (this.filterLevel !== '') {
filtered = filtered.filter(spell => spell.level === this.filterLevel)
}
// Apply ursprung filter
if (this.filterUrsprung) {
filtered = filtered.filter(spell => spell.ursprung === this.filterUrsprung)
}
// Apply Reichweite filter
if (this.filterReichweite) {
filtered = filtered.filter(spell => spell.reichweite === this.filterReichweite)
}
// Apply Wirkungsziel filter
if (this.filterWirkungsziel) {
filtered = filtered.filter(spell => spell.wirkungsziel === this.filterWirkungsziel)
}
// Apply Quelle filter (only by source code, ignoring page number)
if (this.filterQuelle) {
filtered = filtered.filter(spell => {
if (spell.source_id && this.availableSources.length > 0) {
const source = this.availableSources.find(s => s.id === spell.source_id)
return source && source.code === this.filterQuelle
}
return false
})
}
// Apply sorting
filtered.sort((a, b) => {
const aValue = (a[this.sortField] || '').toString().toLowerCase()
const bValue = (b[this.sortField] || '').toString().toLowerCase()
return this.sortAsc ? aValue.localeCompare(bValue) : bValue.localeCompare(aValue)
})
return filtered
},
sortedSpells() {
return [...this.mdata.spells].sort((a, b) => {
const aValue = (a[this.sortField] || '').toLowerCase();
const bValue = (b[this.sortField] || '').toLowerCase();
return this.sortAsc
? aValue.localeCompare(bValue)
: bValue.localeCompare(aValue);
});
},
systemOptions() {
return buildSystemOptions(this.gameSystems)
}
},
methods: {
async loadGameSystems() {
try {
this.gameSystems = await fetchGameSystems()
} catch (error) {
console.error('Failed to load game systems:', error)
}
},
async loadEnhancedSpells() {
try {
const response = await API.get('/api/maintenance/spells-enhanced')
this.enhancedSpells = response.data.spells || []
this.availableSources = response.data.sources || []
// Also update mdata for compatibility
if (response.data.categories) {
this.mdata.spellcategories = response.data.categories
}
} catch (error) {
console.error('Failed to load enhanced spells:', error)
}
},
startEdit(index) {
const spell = this.filteredAndSortedSpells[index]
this.editedItem = {
...spell,
sourceCode: this.getSourceCode(spell.source_id)
}
this.selectedSystemId = spell.game_system_id ?? this.findSystemIdByCode(spell.system)
this.editingIndex = index
},
async saveEdit(index) {
try {
// Find source ID from code
const source = this.availableSources.find(s => s.code === this.editedItem.sourceCode)
const selectedSystem = this.gameSystems.find(gs => gs.id === this.selectedSystemId)
const updateData = {
...this.editedItem,
source_id: source ? source.id : null,
page_number: this.editedItem.page_number || 0,
system: selectedSystem ? selectedSystem.code : (this.editedItem.system || ''),
game_system_id: selectedSystem ? selectedSystem.id : (this.editedItem.game_system_id ?? null)
}
const response = await API.put(
`/api/maintenance/spells-enhanced/${this.editedItem.id}`,
updateData
)
// Update the spell in the list using splice for proper reactivity
const spellIndex = this.enhancedSpells.findIndex(s => s.id === this.editedItem.id)
if (spellIndex !== -1) {
this.enhancedSpells.splice(spellIndex, 1, response.data)
}
this.editingIndex = -1
this.editedItem = null
this.selectedSystemId = null
} catch (error) {
console.error('Failed to save spell:', error)
alert('Failed to save spell: ' + (error.response?.data?.error || error.message))
}
},
cancelEdit() {
this.editingIndex = -1;
this.editedItem = null;
this.selectedSystemId = null;
},
startCreate() {
this.cancelEdit()
const defaultSystem = this.gameSystems.find(gs => gs.is_active) || this.gameSystems[0] || null
this.createSelectedSystemId = defaultSystem ? defaultSystem.id : null
this.newItem = {
name: '',
category: this.mdata.spellcategories?.[0] || '',
level: 0,
ap: '',
zauberdauer: '',
reichweite: '',
wirkungsziel: '',
wirkungsbereich: '',
wirkungsdauer: '',
ursprung: '',
beschreibung: '',
sourceCode: '',
page_number: 0,
system: defaultSystem ? defaultSystem.code : ''
}
this.creatingNew = true
},
cancelCreate() {
this.creatingNew = false
this.newItem = null
this.createSelectedSystemId = null
},
findSystemIdByCode(code) {
return findSystemIdByCode(this.gameSystems, code)
},
sortBy(field) {
if (this.sortField === field) {
this.sortAsc = !this.sortAsc;
} else {
this.sortField = field;
this.sortAsc = true;
}
},
async handleSpellUpdate({ index, spell }) {
try {
const response = await API.put(
`/api/maintenance/spells/${spell.id}`, spell,
{
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}` ,
'Content-Type': 'application/json'
}
}
)
if (!response.statusText== "OK") throw new Error('Update failed');
const updatedSkill = response.data;
// Update the spell in mdata
this.mdata.spells = this.mdata.spells.map(s =>
s.id === updatedSkill.id ? updatedSkill : s
);
} catch (error) {
console.error('Failed to update spell:', error);
}
},
async saveCreate() {
if (!this.newItem) return
try {
const source = this.availableSources.find(s => s.code === this.newItem.sourceCode)
const selectedSystem = this.gameSystems.find(gs => gs.id === this.createSelectedSystemId)
const createData = {
...this.newItem,
source_id: source ? source.id : null,
page_number: this.newItem.page_number || 0,
system: selectedSystem ? selectedSystem.code : (this.newItem.system || ''),
game_system_id: selectedSystem ? selectedSystem.id : null
}
const response = await API.post(
'/api/maintenance/spells-enhanced',
createData
)
this.enhancedSpells.push(response.data)
this.cancelCreate()
} catch (error) {
console.error('Failed to create spell:', error)
alert('Failed to create spell: ' + (error.response?.data?.error || error.message))
}
},
handleFileSelect(event) {
const file = event.target.files[0];
if (file && file.type === 'text/csv') {
this.selectedFile = file;
this.importResult = null;
} else {
this.selectedFile = null;
this.importResult = {
success: false,
message: 'Please select a valid CSV file.'
};
}
},
async importSpells() {
if (!this.selectedFile) {
this.importResult = {
success: false,
message: 'Please select a CSV file first.'
};
return;
}
this.importing = true;
this.importResult = null;
try {
const formData = new FormData();
formData.append('file', this.selectedFile);
const response = await API.post('/api/importer/spells/csv', formData, {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'multipart/form-data'
}
});
this.importResult = {
success: true,
message: response.data.message,
total_spells: response.data.total_spells
};
// Refresh the spells data after successful import
await this.refreshSpellsData();
} catch (error) {
console.error('Failed to import spells:', error);
this.importResult = {
success: false,
message: error.response?.data?.message || 'Import failed. Please try again.'
};
} finally {
this.importing = false;
}
},
async refreshSpellsData() {
try {
const token = localStorage.getItem('token');
const response = await API.get('/api/maintenance', {
headers: { Authorization: `Bearer ${token}` }
});
// Update the spells data
if (response.data.spells) {
this.mdata.spells = response.data.spells;
}
if (response.data.sources) {
this.availableSources = response.data.sources;
}
} catch (error) {
console.error('Failed to refresh spells data:', error);
}
},
formatQuelle(spell) {
if (spell.source_id && this.availableSources.length > 0) {
const source = this.availableSources.find(s => s.id === spell.source_id)
if (source) {
if (spell.page_number) {
return `${source.code}:${spell.page_number}`
} else {
// No page number - show code and quelle if available
const quelle = spell.quelle ? ` (${spell.quelle})` : ''
return `${source.code}${quelle}`
}
}
}
return spell.quelle || '-'
},
getSourceCode(sourceId) {
return getSourceCode(this.availableSources, sourceId)
},
getSystemCodeById(systemId, fallback = '') {
return getSystemCodeById(this.gameSystems, systemId, fallback)
},
clearFilters() {
this.searchTerm = ''
this.filterCategory = ''
this.filterLevel = ''
this.filterUrsprung = ''
this.filterReichweite = ''
this.filterWirkungsziel = ''
this.filterQuelle = ''
}
}
};
</script>
@@ -1,501 +0,0 @@
<template>
<div class="header-section">
<h2>{{ $t('maintenance') }}</h2>
<!-- Add search input -->
<div class="search-box">
<input
type="text"
v-model="searchTerm"
:placeholder="`${$t('search')} ${$t('WaeponSkill')}...`"
/>
<button @click="startCreate" class="btn-primary">{{ $t('newEntry') }}</button>
</div>
</div>
<div class="cd-view">
<div class="cd-list">
<!-- Filter Row -->
<div class="filter-row">
<div class="filter-item">
<label>{{ $t('weaponskill.difficulty') }}:</label>
<select v-model="filterDifficulty">
<option value="">{{ $t('common.all') }}</option>
<option v-for="diff in availableDifficulties" :key="diff" :value="diff">{{ diff }}</option>
</select>
</div>
<div class="filter-item">
<label>{{ $t('weaponskill.quelle') }}:</label>
<select v-model="filterQuelle">
<option value="">{{ $t('common.all') }}</option>
<option v-for="quelle in availableQuellen" :key="quelle" :value="quelle">{{ quelle }}</option>
</select>
</div>
<button @click="clearFilters" class="btn-clear-filters">{{ $t('common.clearFilters') }}</button>
</div>
<div class="tables-container">
<table class="cd-table">
<thead>
<tr>
<th class="cd-table-header">{{ $t('weaponskill.id') }}</th>
<th class="cd-table-header">{{ $t('weaponskill.name') }} <button @click="sortBy('name')">-{{ sortField === 'name' ? (sortAsc ? '' : '') : '' }}</button></th>
<th class="cd-table-header">{{ $t('weaponskill.difficulty') }}<button @click="sortBy('difficulty')">-{{ sortField === 'difficulty' ? (sortAsc ? '' : '') : '' }}</button></th>
<th class="cd-table-header">{{ $t('weaponskill.initialwert') }}</th>
<th class="cd-table-header">{{ $t('weaponskill.description') }}</th>
<th class="cd-table-header">{{ $t('weaponskill.quelle') }}</th>
<th class="cd-table-header">{{ $t('weaponskill.system') }}</th>
<th class="cd-table-header"> </th>
</tr>
</thead>
<tbody>
<tr v-if="creatingNew">
<td>New</td>
<td colspan="6">
<div class="edit-form">
<div class="edit-row">
<div class="edit-field">
<label>{{ $t('weaponskill.name') }}:</label>
<input v-model="newItem.name" />
</div>
<div class="edit-field">
<label>{{ $t('weaponskill.difficulty') }}:</label>
<select v-model="newItem.difficulty" style="width:120px;">
<option value="leicht">leicht</option>
<option value="normal">normal</option>
<option value="schwer">schwer</option>
<option value="sehr schwer">sehr schwer</option>
</select>
</div>
<div class="edit-field">
<label>{{ $t('weaponskill.initialwert') }}:</label>
<input v-model.number="newItem.initialwert" type="number" style="width:60px;" />
</div>
</div>
<div class="edit-row">
<div class="edit-field full-width">
<label>{{ $t('weaponskill.description') }}:</label>
<input v-model="newItem.beschreibung" />
</div>
</div>
<div class="edit-row">
<div class="edit-field">
<label>{{ $t('weaponskill.quelle') }}:</label>
<select v-model="newItem.sourceCode" style="width:100px;">
<option value="">-</option>
<option v-for="source in availableSources" :key="source.code" :value="source.code">
{{ source.code }}
</option>
</select>
</div>
<div class="edit-field">
<label>{{ $t('weaponskill.page') || 'Page' }}:</label>
<input v-model.number="newItem.page_number" type="number" style="width:60px;" />
</div>
<div class="edit-field">
<label>{{ $t('weaponskill.system') }}:</label>
<select v-model.number="createSelectedSystemId" style="width:140px;">
<option value="">-</option>
<option v-for="system in systemOptions" :key="system.id" :value="system.id">
{{ system.label }}
</option>
</select>
</div>
</div>
<div class="edit-actions">
<button @click="saveCreate" class="btn-save">{{ $t('common.save') }}</button>
<button @click="cancelCreate" class="btn-cancel">{{ $t('common.cancel') }}</button>
</div>
</div>
</td>
</tr>
<template v-for="(dtaItem, index) in filteredAndSortedWaeponSkills" :key="dtaItem.id">
<tr v-if="editingIndex !== index">
<td>{{ dtaItem.id || '' }}</td>
<td>{{ dtaItem.name || '-' }}</td>
<td>{{ dtaItem.difficulty || '-' }}</td>
<td>{{ dtaItem.initialwert || '0' }}</td>
<td>{{ dtaItem.beschreibung || '-' }}</td>
<td>{{ formatQuelle(dtaItem) }}</td>
<td>{{ getSystemCodeById(dtaItem.game_system_id, dtaItem.system || 'midgard') }}</td>
<td>
<button @click="startEdit(index)">{{ $t('common.edit') }}</button>
</td>
</tr>
<!-- Edit Mode -->
<tr v-else>
<td><input v-model="editedItem.id" style="width:20px;" disabled /></td>
<td colspan="6">
<!-- Expanded edit form -->
<div class="edit-form">
<div class="edit-row">
<div class="edit-field">
<label>{{ $t('weaponskill.name') }}:</label>
<input v-model="editedItem.name" />
</div>
<div class="edit-field">
<label>{{ $t('weaponskill.difficulty') }}:</label>
<select v-model="editedItem.difficulty" style="width:120px;">
<option value="leicht">leicht</option>
<option value="normal">normal</option>
<option value="schwer">schwer</option>
<option value="sehr schwer">sehr schwer</option>
</select>
</div>
<div class="edit-field">
<label>{{ $t('weaponskill.initialwert') }}:</label>
<input v-model.number="editedItem.initialwert" type="number" style="width:60px;" />
</div>
</div>
<div class="edit-row">
<div class="edit-field full-width">
<label>{{ $t('weaponskill.description') }}:</label>
<input v-model="editedItem.beschreibung" />
</div>
</div>
<div class="edit-row">
<div class="edit-field">
<label>{{ $t('weaponskill.quelle') }}:</label>
<select v-model="editedItem.sourceCode" style="width:100px;">
<option value="">-</option>
<option v-for="source in availableSources" :key="source.code" :value="source.code">
{{ source.code }}
</option>
</select>
</div>
<div class="edit-field">
<label>{{ $t('weaponskill.page') || 'Page' }}:</label>
<input v-model.number="editedItem.page_number" type="number" style="width:60px;" />
</div>
<div class="edit-field">
<label>{{ $t('weaponskill.system') }}:</label>
<select v-model.number="selectedSystemId" style="width:140px;">
<option value="">-</option>
<option v-for="system in systemOptions" :key="system.id" :value="system.id">
{{ system.label }}
</option>
</select>
</div>
</div>
<div class="edit-actions">
<button @click="saveEdit(index)" class="btn-save">{{ $t('common.save') }}</button>
<button @click="cancelEdit" class="btn-cancel">{{ $t('common.cancel') }}</button>
</div>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div> <!--- end cd-list-->
</div> <!--- end character -datasheet-->
</template>
<!-- <style scoped> -->
<style>
.header-section {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.3rem;
height: fit-content;
padding: 0.5rem;
}
.search-box {
margin-bottom: 1rem;
}
.search-box input {
padding: 0.2rem;
width: 200px;
border: 1px solid #ddd;
border-radius: 4px;
}
.tables-container {
display: flex;
gap: 1rem;
width: 100%;
}
.table-wrapper-left {
flex: 6;
min-width: 0; /* Prevent table from overflowing */
}
.table-wrapper-right {
flex: 4;
min-width: 0; /* Prevent table from overflowing */
}
.cd-table {
width: 100%;
}
.cd-table-header {
background-color: #1da766;
font-weight: bold;
}
</style>
<script>
import API from '../../utils/api'
import {
findSystemIdByCode,
getSourceCode,
getSystemCodeById,
loadGameSystems as fetchGameSystems,
buildSystemOptions,
} from '../../utils/maintenanceGameSystems'
export default {
name: "WaeponSkillView",
props: {
mdata: {
type: Object,
required: true,
default: () => ({
skills: [],
skillcategories: []
})
}
},
data() {
return {
searchTerm: '',
sortField: 'name',
sortAsc: true,
editingIndex: -1,
editedItem: null,
filterDifficulty: '',
filterQuelle: '',
enhancedWeaponSkills: [],
availableSources: [],
availableDifficultiesData: [],
gameSystems: [],
selectedSystemId: null,
creatingNew: false,
newItem: null,
createSelectedSystemId: null
}
},
async created() {
await Promise.all([
this.loadGameSystems(),
this.loadEnhancedWeaponSkills()
])
},
computed: {
availableDifficulties() {
const difficulties = new Set()
this.enhancedWeaponSkills.forEach(ws => {
if (ws.difficulty) difficulties.add(ws.difficulty)
})
return Array.from(difficulties).sort()
},
availableQuellen() {
const quellen = new Set()
this.enhancedWeaponSkills.forEach(ws => {
if (ws.source_id && this.availableSources.length > 0) {
const source = this.availableSources.find(s => s.id === ws.source_id)
if (source) {
quellen.add(source.code)
}
}
})
return Array.from(quellen).sort()
},
filteredAndSortedWaeponSkills() {
let filtered = [...this.enhancedWeaponSkills]
// Apply search filter
if (this.searchTerm) {
const searchLower = this.searchTerm.toLowerCase()
filtered = filtered.filter(ws =>
ws.name?.toLowerCase().includes(searchLower) ||
ws.difficulty?.toLowerCase().includes(searchLower)
)
}
// Apply difficulty filter
if (this.filterDifficulty) {
filtered = filtered.filter(ws => ws.difficulty === this.filterDifficulty)
}
// Apply Quelle filter (only by source code, ignoring page number)
if (this.filterQuelle) {
filtered = filtered.filter(ws => {
if (ws.source_id && this.availableSources.length > 0) {
const source = this.availableSources.find(s => s.id === ws.source_id)
return source && source.code === this.filterQuelle
}
return false
})
}
// Apply sorting
filtered.sort((a, b) => {
const aValue = (a[this.sortField] || '').toString().toLowerCase()
const bValue = (b[this.sortField] || '').toString().toLowerCase()
return this.sortAsc ? aValue.localeCompare(bValue) : bValue.localeCompare(aValue)
})
return filtered
},
systemOptions() {
return buildSystemOptions(this.gameSystems)
}
},
methods: {
async loadGameSystems() {
try {
this.gameSystems = await fetchGameSystems()
} catch (error) {
console.error('Failed to load game systems:', error)
}
},
async loadEnhancedWeaponSkills() {
try {
const response = await API.get('/api/maintenance/weaponskills-enhanced')
this.enhancedWeaponSkills = response.data.weaponskills || []
this.availableSources = response.data.sources || []
this.availableDifficultiesData = response.data.difficulties || []
} catch (error) {
console.error('Failed to load enhanced weapon skills:', error)
}
},
startEdit(index) {
const weaponSkill = this.filteredAndSortedWaeponSkills[index]
this.editedItem = {
...weaponSkill,
sourceCode: this.getSourceCode(weaponSkill.source_id)
}
this.selectedSystemId = weaponSkill.game_system_id ?? this.findSystemIdByCode(weaponSkill.system)
this.editingIndex = index
},
async saveEdit(index) {
try {
// Find source ID from code
const source = this.availableSources.find(s => s.code === this.editedItem.sourceCode)
const selectedSystem = this.gameSystems.find(gs => gs.id === this.selectedSystemId)
const updateData = {
...this.editedItem,
source_id: source ? source.id : null,
page_number: this.editedItem.page_number || 0,
difficulty: this.editedItem.difficulty,
category: 'Waffen', // Weapon skills always use 'Waffen' category
system: selectedSystem ? selectedSystem.code : (this.editedItem.system || ''),
game_system_id: selectedSystem ? selectedSystem.id : (this.editedItem.game_system_id ?? null)
}
const response = await API.put(
`/api/maintenance/weaponskills-enhanced/${this.editedItem.id}`,
updateData
)
// Reload the list to get updated difficulty from backend
await this.loadEnhancedWeaponSkills()
this.editingIndex = -1
this.editedItem = null
this.selectedSystemId = null
} catch (error) {
console.error('Failed to save weapon skill:', error)
alert('Failed to save weapon skill: ' + (error.response?.data?.error || error.message))
}
},
cancelEdit() {
this.editingIndex = -1
this.editedItem = null
this.selectedSystemId = null
},
startCreate() {
this.cancelEdit()
const defaultSystem = this.gameSystems.find(gs => gs.is_active) || this.gameSystems[0] || null
this.createSelectedSystemId = defaultSystem ? defaultSystem.id : null
this.newItem = {
name: '',
difficulty: 'leicht',
initialwert: 0,
beschreibung: '',
sourceCode: '',
page_number: 0,
system: defaultSystem ? defaultSystem.code : ''
}
this.creatingNew = true
},
cancelCreate() {
this.creatingNew = false
this.newItem = null
this.createSelectedSystemId = null
},
async saveCreate() {
if (!this.newItem) return
try {
const source = this.availableSources.find(s => s.code === this.newItem.sourceCode)
const selectedSystem = this.gameSystems.find(gs => gs.id === this.createSelectedSystemId)
const createData = {
...this.newItem,
category: 'Waffen',
source_id: source ? source.id : null,
page_number: this.newItem.page_number || 0,
system: selectedSystem ? selectedSystem.code : (this.newItem.system || ''),
game_system_id: selectedSystem ? selectedSystem.id : null
}
const response = await API.post(
'/api/maintenance/weaponskills-enhanced',
createData
)
this.enhancedWeaponSkills.push(response.data)
this.cancelCreate()
} catch (error) {
console.error('Failed to create weapon skill:', error)
alert('Failed to create weapon skill: ' + (error.response?.data?.error || error.message))
}
},
findSystemIdByCode(code) {
return findSystemIdByCode(this.gameSystems, code)
},
sortBy(field) {
if (this.sortField === field) {
this.sortAsc = !this.sortAsc
} else {
this.sortField = field
this.sortAsc = true
}
},
formatQuelle(weaponSkill) {
if (weaponSkill.source_id && this.availableSources.length > 0) {
const source = this.availableSources.find(s => s.id === weaponSkill.source_id)
if (source) {
if (weaponSkill.page_number) {
return `${source.code}:${weaponSkill.page_number}`
} else {
// No page number - show code and quelle if available
const quelle = weaponSkill.quelle ? ` (${weaponSkill.quelle})` : ''
return `${source.code}${quelle}`
}
}
}
return weaponSkill.quelle || '-'
},
getSourceCode(sourceId) {
return getSourceCode(this.availableSources, sourceId)
},
getSystemCodeById(systemId, fallback = '') {
return getSystemCodeById(this.gameSystems, systemId, fallback)
},
clearFilters() {
this.searchTerm = ''
this.filterDifficulty = ''
this.filterQuelle = ''
}
}
};
</script>
@@ -1,685 +0,0 @@
<template>
<div class="header-section">
<h2>{{ $t('maintenance') }}</h2>
<!-- Add search input -->
<div class="search-box">
<input
type="text"
v-model="searchTerm"
:placeholder="`${$t('search')} ${$t('Weapons')}...`"
/>
<button @click="startCreate" class="btn-primary">{{ $t('newEntry') }}</button>
</div>
</div>
<div class="cd-view">
<div class="cd-list">
<!-- Filter Row -->
<div class="filter-row">
<div class="filter-item">
<label>{{ $t('weapon.skillrequired') }}:</label>
<select v-model="filterSkillRequired">
<option value="">{{ $t('common.all') }}</option>
<option v-for="skill in availableSkillsRequired" :key="skill" :value="skill">{{ skill }}</option>
</select>
</div>
<div class="filter-item">
<label>{{ $t('weapon.damage') }}:</label>
<select v-model="filterDamage">
<option value="">{{ $t('common.all') }}</option>
<option v-for="dmg in availableDamages" :key="dmg" :value="dmg">{{ dmg }}</option>
</select>
</div>
<div class="filter-item">
<label>{{ $t('weapon.rangenear') }}:</label>
<select v-model="filterRangeNear">
<option value="">{{ $t('common.all') }}</option>
<option v-for="range in availableRangesNear" :key="range" :value="range">{{ range }}</option>
</select>
</div>
<div class="filter-item">
<label>{{ $t('weapon.rangemiddle') }}:</label>
<select v-model="filterRangeMiddle">
<option value="">{{ $t('common.all') }}</option>
<option v-for="range in availableRangesMiddle" :key="range" :value="range">{{ range }}</option>
</select>
</div>
<div class="filter-item">
<label>{{ $t('weapon.rangefar') }}:</label>
<select v-model="filterRangeFar">
<option value="">{{ $t('common.all') }}</option>
<option v-for="range in availableRangesFar" :key="range" :value="range">{{ range }}</option>
</select>
</div>
<div class="filter-item">
<label>{{ $t('weapon.quelle') }}:</label>
<select v-model="filterQuelle">
<option value="">{{ $t('common.all') }}</option>
<option v-for="quelle in availableQuellen" :key="quelle" :value="quelle">{{ quelle }}</option>
</select>
</div>
<button @click="clearFilters" class="btn-clear-filters">{{ $t('common.clearFilters') }}</button>
</div>
<div class="tables-container">
<table class="cd-table">
<thead>
<tr>
<th class="cd-table-header">{{ $t('weapon.id') }}</th>
<!-- <th class="cd-table-header">{{ $t('weapon.category') }}<button @click="sortBy('category')">-{{ sortField === 'category' ? (sortAsc ? '' : '') : '' }}</button></th> -->
<th class="cd-table-header">{{ $t('weapon.name') }} <button @click="sortBy('name')">-{{ sortField === 'name' ? (sortAsc ? '' : '') : '' }}</button></th>
<th class="cd-table-header">{{ $t('weapon.skillrequired') || 'Skill Required' }}</th>
<th class="cd-table-header">{{ $t('weapon.weight') }}</th>
<th class="cd-table-header">{{ $t('weapon.value') }}</th>
<th class="cd-table-header">{{ $t('weapon.damage') }}</th>
<th class="cd-table-header">{{ $t('weapon.rangenear') || 'Range Near' }}</th>
<th class="cd-table-header">{{ $t('weapon.rangemiddle') || 'Range Middle' }}</th>
<th class="cd-table-header">{{ $t('weapon.rangefar') || 'Range Far' }}</th>
<th class="cd-table-header">{{ $t('weapon.description') }}</th>
<th class="cd-table-header">{{ $t('weapon.quelle') }}</th>
<th class="cd-table-header">{{ $t('weapon.personal_item') }}</th>
<th class="cd-table-header">{{ $t('weapon.system') }}</th>
<th class="cd-table-header"> </th>
</tr>
</thead>
<tbody>
<tr v-if="creatingNew">
<td>New</td>
<td colspan="11">
<div class="edit-form">
<div class="edit-row">
<div class="edit-field">
<label>{{ $t('weapon.name') }}:</label>
<input v-model="newItem.name" />
</div>
<div class="edit-field">
<label>{{ $t('weapon.skillrequired') || 'Skill Required' }}:</label>
<input v-model="newItem.skill_required" style="width:150px;" />
</div>
<div class="edit-field">
<label>{{ $t('weapon.weight') }}:</label>
<input v-model.number="newItem.gewicht" type="number" style="width:80px;" />
</div>
<div class="edit-field">
<label>{{ $t('weapon.value') }}:</label>
<input v-model="newItem.wert" style="width:100px;" />
</div>
</div>
<div class="edit-row">
<div class="edit-field">
<label>{{ $t('weapon.damage') }}:</label>
<input v-model="newItem.damage" style="width:100px;" />
</div>
<div class="edit-field">
<label>{{ $t('weapon.rangenear') || 'Range Near' }}:</label>
<input v-model.number="newItem.range_near" type="number" style="width:80px;" />
</div>
<div class="edit-field">
<label>{{ $t('weapon.rangemiddle') || 'Range Middle' }}:</label>
<input v-model.number="newItem.range_middle" type="number" style="width:80px;" />
</div>
<div class="edit-field">
<label>{{ $t('weapon.rangefar') || 'Range Far' }}:</label>
<input v-model.number="newItem.range_far" type="number" style="width:80px;" />
</div>
</div>
<div class="edit-row">
<div class="edit-field">
<label>{{ $t('weapon.bonusskill') || 'Bonus' }}:</label>
<select v-model="newItem.bonuseigenschaft" style="width:80px;">
<option value="">-</option>
<option value="St">St</option>
<option value="Gs">Gs</option>
<option value="Gw">Gw</option>
<option value="Ko">Ko</option>
<option value="In">In</option>
<option value="Zt">Zt</option>
<option value="Au">Au</option>
<option value="pA">pA</option>
<option value="Wk">Wk</option>
<option value="B">B</option>
</select>
</div>
<div class="edit-field">
<label>{{ $t('weapon.personal_item') }}:</label>
<input type="checkbox" v-model="newItem.personal_item" />
</div>
</div>
<div class="edit-row">
<div class="edit-field full-width">
<label>{{ $t('weapon.description') }}:</label>
<input v-model="newItem.beschreibung" />
</div>
</div>
<div class="edit-row">
<div class="edit-field">
<label>{{ $t('weapon.quelle') }}:</label>
<select v-model="newItem.sourceCode" style="width:100px;">
<option value="">-</option>
<option v-for="source in availableSources" :key="source.code" :value="source.code">
{{ source.code }}
</option>
</select>
</div>
<div class="edit-field">
<label>{{ $t('weapon.page') || 'Page' }}:</label>
<input v-model.number="newItem.page_number" type="number" style="width:60px;" />
</div>
<div class="edit-field">
<label>{{ $t('weapon.system') }}:</label>
<select v-model.number="createSelectedSystemId" style="width:140px;">
<option value="">-</option>
<option v-for="system in systemOptions" :key="system.id" :value="system.id">
{{ system.label }}
</option>
</select>
</div>
</div>
<div class="edit-actions">
<button @click="saveCreate" class="btn-save">{{ $t('common.save') }}</button>
<button @click="cancelCreate" class="btn-cancel">{{ $t('common.cancel') }}</button>
</div>
</div>
</td>
</tr>
<template v-for="(dtaItem, index) in filteredAndSortedWeaponss" :key="dtaItem.id">
<tr v-if="editingIndex !== index">
<td>{{ dtaItem.id || '' }}</td>
<td>{{ dtaItem.name || '-' }}</td>
<td>{{ dtaItem.skill_required || '-' }}</td>
<td>{{ dtaItem.gewicht || '-' }}</td>
<td>{{ dtaItem.wert || '-' }}</td>
<td>{{ dtaItem.damage || '-' }}</td>
<td>{{ dtaItem.range_near || '-' }}</td>
<td>{{ dtaItem.range_middle || '-' }}</td>
<td>{{ dtaItem.range_far || '-' }}</td>
<td>{{ dtaItem.beschreibung || '-' }}</td>
<td>{{ formatQuelle(dtaItem) }}</td>
<td><input type="checkbox" :checked="dtaItem.personal_item" disabled /></td>
<td>{{ getSystemCodeById(dtaItem.game_system_id, dtaItem.system || 'midgard') }}</td>
<td>
<button @click="startEdit(index)">{{ $t('common.edit') }}</button>
</td>
</tr>
<!-- Edit Mode -->
<tr v-else>
<td><input v-model="editedItem.id" style="width:20px;" disabled /></td>
<td colspan="11">
<!-- Expanded edit form -->
<div class="edit-form">
<div class="edit-row">
<div class="edit-field">
<label>{{ $t('weapon.name') }}:</label>
<input v-model="editedItem.name" />
</div>
<div class="edit-field">
<label>{{ $t('weapon.skillrequired') || 'Skill Required' }}:</label>
<input v-model="editedItem.skill_required" style="width:150px;" />
</div>
<div class="edit-field">
<label>{{ $t('weapon.weight') }}:</label>
<input v-model.number="editedItem.gewicht" type="number" style="width:80px;" />
</div>
<div class="edit-field">
<label>{{ $t('weapon.value') }}:</label>
<input v-model="editedItem.wert" style="width:100px;" />
</div>
</div>
<div class="edit-row">
<div class="edit-field">
<label>{{ $t('weapon.damage') }}:</label>
<input v-model="editedItem.damage" style="width:100px;" />
</div>
<div class="edit-field">
<label>{{ $t('weapon.rangenear') || 'Range Near' }}:</label>
<input v-model.number="editedItem.range_near" type="number" style="width:80px;" />
</div>
<div class="edit-field">
<label>{{ $t('weapon.rangemiddle') || 'Range Middle' }}:</label>
<input v-model.number="editedItem.range_middle" type="number" style="width:80px;" />
</div>
<div class="edit-field">
<label>{{ $t('weapon.rangefar') || 'Range Far' }}:</label>
<input v-model.number="editedItem.range_far" type="number" style="width:80px;" />
</div>
</div>
<div class="edit-row">
<div class="edit-field">
<label>{{ $t('weapon.bonusskill') || 'Bonus' }}:</label>
<select v-model="editedItem.bonuseigenschaft" style="width:80px;">
<option value="">-</option>
<option value="St">St</option>
<option value="Gs">Gs</option>
<option value="Gw">Gw</option>
<option value="Ko">Ko</option>
<option value="In">In</option>
<option value="Zt">Zt</option>
<option value="Au">Au</option>
<option value="pA">pA</option>
<option value="Wk">Wk</option>
<option value="B">B</option>
</select>
</div>
<div class="edit-field">
<label>{{ $t('weapon.personal_item') }}:</label>
<input type="checkbox" v-model="editedItem.personal_item" />
</div>
</div>
<div class="edit-row">
<div class="edit-field full-width">
<label>{{ $t('weapon.description') }}:</label>
<input v-model="editedItem.beschreibung" />
</div>
</div>
<div class="edit-row">
<div class="edit-field">
<label>{{ $t('weapon.quelle') }}:</label>
<select v-model="editedItem.sourceCode" style="width:100px;">
<option value="">-</option>
<option v-for="source in availableSources" :key="source.code" :value="source.code">
{{ source.code }}
</option>
</select>
</div>
<div class="edit-field">
<label>{{ $t('weapon.page') || 'Page' }}:</label>
<input v-model.number="editedItem.page_number" type="number" style="width:60px;" />
</div>
<div class="edit-field">
<label>{{ $t('weapon.system') }}:</label>
<select v-model.number="selectedSystemId" style="width:140px;">
<option value="">-</option>
<option v-for="system in systemOptions" :key="system.id" :value="system.id">
{{ system.label }}
</option>
</select>
</div>
</div>
<div class="edit-actions">
<button @click="saveEdit(index)" class="btn-save">{{ $t('common.save') }}</button>
<button @click="cancelEdit" class="btn-cancel">{{ $t('common.cancel') }}</button>
</div>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div> <!--- end cd-list-->
</div> <!--- end character -datasheet-->
</template>
<!-- <style scoped> -->
<style>
.header-section {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.3rem;
height: fit-content;
padding: 0.5rem;
}
.search-box {
margin-bottom: 1rem;
}
.search-box input {
padding: 0.2rem;
width: 200px;
border: 1px solid #ddd;
border-radius: 4px;
}
.tables-container {
display: flex;
gap: 1rem;
width: 100%;
}
.table-wrapper-left {
flex: 6;
min-width: 0; /* Prevent table from overflowing */
}
.table-wrapper-right {
flex: 4;
min-width: 0; /* Prevent table from overflowing */
}
.cd-table {
width: 100%;
}
.cd-table-header {
background-color: #1da766;
font-weight: bold;
}
</style>
<script>
import API from '../../utils/api'
import {
findSystemIdByCode,
getSourceCode,
getSystemCodeById,
loadGameSystems as fetchGameSystems,
buildSystemOptions,
} from '../../utils/maintenanceGameSystems'
export default {
name: "WeaponView",
props: {
mdata: {
type: Object,
required: true,
default: () => ({
weaponss: [],
weaponscategories: []
})
}
},
data() {
return {
searchTerm: '',
sortField: 'name',
sortAsc: true,
editingIndex: -1,
editedItem: null,
filterSkillRequired: '',
filterDamage: '',
filterRangeNear: '',
filterRangeMiddle: '',
filterRangeFar: '',
filterQuelle: '',
enhancedWeapons: [],
availableSources: [],
gameSystems: [],
selectedSystemId: null,
creatingNew: false,
newItem: null,
createSelectedSystemId: null
}
},
async created() {
await Promise.all([
this.loadGameSystems(),
this.loadEnhancedWeapons()
])
},
computed: {
availableSkillsRequired() {
const skills = new Set()
this.enhancedWeapons.forEach(w => {
if (w.skill_required) skills.add(w.skill_required)
})
return Array.from(skills).sort()
},
availableDamages() {
const damages = new Set()
this.enhancedWeapons.forEach(w => {
if (w.damage) damages.add(w.damage)
})
return Array.from(damages).sort()
},
availableRangesNear() {
const ranges = new Set()
this.enhancedWeapons.forEach(w => {
if (w.range_near !== null && w.range_near !== undefined) ranges.add(w.range_near)
})
return Array.from(ranges).sort((a, b) => a - b)
},
availableRangesMiddle() {
const ranges = new Set()
this.enhancedWeapons.forEach(w => {
if (w.range_middle !== null && w.range_middle !== undefined) ranges.add(w.range_middle)
})
return Array.from(ranges).sort((a, b) => a - b)
},
availableRangesFar() {
const ranges = new Set()
this.enhancedWeapons.forEach(w => {
if (w.range_far !== null && w.range_far !== undefined) ranges.add(w.range_far)
})
return Array.from(ranges).sort((a, b) => a - b)
},
availableQuellen() {
const quellen = new Set()
this.enhancedWeapons.forEach(w => {
if (w.source_id && this.availableSources.length > 0) {
const source = this.availableSources.find(s => s.id === w.source_id)
if (source) {
quellen.add(source.code)
}
}
})
return Array.from(quellen).sort()
},
filteredAndSortedWeaponss() {
let filtered = [...this.enhancedWeapons]
// Apply search filter
if (this.searchTerm) {
const searchLower = this.searchTerm.toLowerCase()
filtered = filtered.filter(w =>
w.name?.toLowerCase().includes(searchLower) ||
w.skill_required?.toLowerCase().includes(searchLower)
)
}
// Apply skill_required filter
if (this.filterSkillRequired) {
filtered = filtered.filter(w => w.skill_required === this.filterSkillRequired)
}
// Apply damage filter
if (this.filterDamage) {
filtered = filtered.filter(w => w.damage === this.filterDamage)
}
// Apply range_near filter
if (this.filterRangeNear !== '') {
filtered = filtered.filter(w => w.range_near === this.filterRangeNear)
}
// Apply range_middle filter
if (this.filterRangeMiddle !== '') {
filtered = filtered.filter(w => w.range_middle === this.filterRangeMiddle)
}
// Apply range_far filter
if (this.filterRangeFar !== '') {
filtered = filtered.filter(w => w.range_far === this.filterRangeFar)
}
// Apply Quelle filter (only by source code, ignoring page number)
if (this.filterQuelle) {
filtered = filtered.filter(w => {
if (w.source_id && this.availableSources.length > 0) {
const source = this.availableSources.find(s => s.id === w.source_id)
return source && source.code === this.filterQuelle
}
return false
})
}
// Apply sorting
filtered.sort((a, b) => {
const aValue = (a[this.sortField] || '').toString().toLowerCase()
const bValue = (b[this.sortField] || '').toString().toLowerCase()
return this.sortAsc ? aValue.localeCompare(bValue) : bValue.localeCompare(aValue)
})
return filtered
},
systemOptions() {
return buildSystemOptions(this.gameSystems)
}
},
methods: {
async loadGameSystems() {
try {
this.gameSystems = await fetchGameSystems()
} catch (error) {
console.error('Failed to load game systems:', error)
}
},
async loadEnhancedWeapons() {
try {
const response = await API.get('/api/maintenance/weapons-enhanced')
this.enhancedWeapons = response.data.weapons || []
this.availableSources = response.data.sources || []
} catch (error) {
console.error('Failed to load enhanced weapons:', error)
}
},
startEdit(index) {
const weapon = this.filteredAndSortedWeaponss[index]
this.editedItem = {
...weapon,
sourceCode: this.getSourceCode(weapon.source_id)
}
this.selectedSystemId = weapon.game_system_id ?? this.findSystemIdByCode(weapon.system)
this.editingIndex = index
},
async saveEdit(index) {
try {
// Find source ID from code
const source = this.availableSources.find(s => s.code === this.editedItem.sourceCode)
const selectedSystem = this.gameSystems.find(gs => gs.id === this.selectedSystemId)
const updateData = {
...this.editedItem,
source_id: source ? source.id : null,
page_number: this.editedItem.page_number || 0,
system: selectedSystem ? selectedSystem.code : (this.editedItem.system || ''),
game_system_id: selectedSystem ? selectedSystem.id : (this.editedItem.game_system_id ?? null)
}
const response = await API.put(
`/api/maintenance/weapons-enhanced/${this.editedItem.id}`,
updateData
)
// Update the weapon in the list using splice for proper reactivity
const weaponIndex = this.enhancedWeapons.findIndex(w => w.id === this.editedItem.id)
if (weaponIndex !== -1) {
this.enhancedWeapons.splice(weaponIndex, 1, response.data)
}
this.editingIndex = -1
this.editedItem = null
this.selectedSystemId = null
} catch (error) {
console.error('Failed to save weapon:', error)
alert('Failed to save weapon: ' + (error.response?.data?.error || error.message))
}
},
cancelEdit() {
this.editingIndex = -1
this.editedItem = null
this.selectedSystemId = null
},
startCreate() {
this.cancelEdit()
const defaultSystem = this.gameSystems.find(gs => gs.is_active) || this.gameSystems[0] || null
this.createSelectedSystemId = defaultSystem ? defaultSystem.id : null
this.newItem = {
name: '',
skill_required: '',
gewicht: 0,
wert: '',
damage: '',
range_near: 0,
range_middle: 0,
range_far: 0,
beschreibung: '',
bonuseigenschaft: '',
personal_item: false,
sourceCode: '',
page_number: 0,
system: defaultSystem ? defaultSystem.code : ''
}
this.creatingNew = true
},
cancelCreate() {
this.creatingNew = false
this.newItem = null
this.createSelectedSystemId = null
},
async saveCreate() {
if (!this.newItem) return
try {
const source = this.availableSources.find(s => s.code === this.newItem.sourceCode)
const selectedSystem = this.gameSystems.find(gs => gs.id === this.createSelectedSystemId)
const createData = {
...this.newItem,
source_id: source ? source.id : null,
page_number: this.newItem.page_number || 0,
system: selectedSystem ? selectedSystem.code : (this.newItem.system || ''),
game_system_id: selectedSystem ? selectedSystem.id : null
}
const response = await API.post(
'/api/maintenance/weapons-enhanced',
createData
)
this.enhancedWeapons.push(response.data)
this.cancelCreate()
} catch (error) {
console.error('Failed to create weapon:', error)
alert('Failed to create weapon: ' + (error.response?.data?.error || error.message))
}
},
findSystemIdByCode(code) {
return findSystemIdByCode(this.gameSystems, code)
},
sortBy(field) {
if (this.sortField === field) {
this.sortAsc = !this.sortAsc
} else {
this.sortField = field
this.sortAsc = true
}
},
formatQuelle(weapon) {
if (weapon.source_id && this.availableSources.length > 0) {
const source = this.availableSources.find(s => s.id === weapon.source_id)
if (source) {
if (weapon.page_number) {
return `${source.code}:${weapon.page_number}`
} else {
// No page number - show code and quelle if available
const quelle = weapon.quelle ? ` (${weapon.quelle})` : ''
return `${source.code}${quelle}`
}
}
}
return weapon.quelle || '-'
},
getSourceCode(sourceId) {
return getSourceCode(this.availableSources, sourceId)
},
getSystemCodeById(systemId, fallback = '') {
return getSystemCodeById(this.gameSystems, systemId, fallback)
},
clearFilters() {
this.searchTerm = ''
this.filterSkillRequired = ''
this.filterDamage = ''
this.filterRangeNear = ''
this.filterRangeMiddle = ''
this.filterRangeFar = ''
this.filterQuelle = ''
}
}
};
</script>
-12
View File
@@ -11,16 +11,11 @@ import ResetPasswordView from "../views/ResetPasswordView.vue";
// Lazy-loaded views (code-split into separate chunks)
const DashboardView = () => import("../views/DashboardView.vue");
const AusruestungView = () => import("../views/AusruestungView.vue");
const MaintenanceView = () => import("../views/MaintenanceView.vue");
const FileUploadPage = () => import("../views/FileUploadPage.vue");
const UserProfileView = () => import("../views/UserProfileView.vue");
const UserManagementView = () => import("../views/UserManagementView.vue");
const SponsorsView = () => import("../views/SponsorsView.vue");
const HelpView = () => import("../views/HelpView.vue");
const SystemInfoView = () => import("../views/SystemInfoView.vue");
const CharacterDetails = () => import("@/components/CharacterDetails.vue");
const CharacterCreation = () => import("@/components/CharacterCreation.vue");
@@ -34,16 +29,9 @@ const routes = [
{ path: "/dashboard", name: "Dashboard", component: DashboardView, meta: { requiresAuth: true } },
{ path: "/profile", name: "UserProfile", component: UserProfileView, meta: { requiresAuth: true } },
{ path: "/users", name: "UserManagement", component: UserManagementView, meta: { requiresAuth: true, requiresAdmin: true } },
{ path: "/ausruestung/:characterId", name: "Ausruestung", component: AusruestungView, meta: { requiresAuth: true } },
{ path: "/maintenance", name: "Maintenance", component: MaintenanceView, meta: { requiresAuth: true } },
{ path: "/upload", name: "FileUpload", component: FileUploadPage, meta: { requiresAuth: true } },
{ path: "/sponsors", name: "Sponsors", component: SponsorsView },
{ path: "/help", name: "Help", component: HelpView },
{ path: "/system-info", name: "SystemInfo", component: SystemInfoView },
// Route for character details // Pass route params as props to the component
{ path: "/character/:id", name: "CharacterDetails", component: CharacterDetails, props: true, meta: { requiresAuth: true } },
// Route for character creation
{ path: "/character/create/:sessionId", name: "CharacterCreation", component: CharacterCreation, props: true, meta: { requiresAuth: true } },
];
const router = createRouter({
-145
View File
@@ -1,145 +0,0 @@
import { defineStore } from 'pinia'
export const useCharacterCreationStore = defineStore('characterCreation', {
state: () => ({
sessionData: {
basic_info: null,
attributes: null,
derived_values: null,
skills: [],
skills_meta: {
totalUsedPoints: 0,
selectedCategory: null
},
spells: [],
equipment: null
},
currentStep: 1,
sessionId: null,
isLoading: false,
error: null
}),
getters: {
characterClass: (state) => state.sessionData.basic_info?.typ || 'Barbar',
characterStand: (state) => state.sessionData.basic_info?.stand || 'Buerger',
characterRace: (state) => state.sessionData.basic_info?.rasse || 'Menschen',
hasSelectedSkills: (state) => state.sessionData.skills && state.sessionData.skills.length > 0,
totalSkillPoints: (state) => state.sessionData.skills_meta?.totalUsedPoints || 0,
isValid: (state) => {
// Basic validation - can be extended as needed
return !!(state.sessionData.basic_info &&
state.sessionData.attributes &&
state.sessionData.derived_values)
}
},
actions: {
// Initialize or load existing session
async initializeSession(sessionId = null) {
this.isLoading = true
this.error = null
try {
if (sessionId) {
// Load existing session from backend
await this.loadSession(sessionId)
} else {
// Create new session
await this.createNewSession()
}
} catch (error) {
this.error = error.message
console.error('Error initializing session:', error)
} finally {
this.isLoading = false
}
},
// Create new character creation session
async createNewSession() {
// For now, just initialize empty session
// In future, this could call backend to create session
this.sessionData = {
basic_info: null,
attributes: null,
derived_values: null,
skills: [],
skills_meta: {
totalUsedPoints: 0,
selectedCategory: null
},
spells: [],
equipment: null
}
this.currentStep = 1
console.log('Created new character creation session')
},
// Load existing session from backend
async loadSession(sessionId) {
// TODO: Implement backend call to load session
this.sessionId = sessionId
console.log('Loading session:', sessionId)
},
// Save session data to backend
async saveSession() {
if (!this.sessionId) {
console.warn('No session ID available for saving')
return
}
try {
// TODO: Implement backend call to save session
console.log('Saving session data:', this.sessionData)
} catch (error) {
console.error('Error saving session:', error)
throw error
}
},
// Update specific step data
updateStepData(stepData) {
Object.assign(this.sessionData, stepData)
console.log('Updated session data:', stepData)
// Auto-save after update
this.saveSession()
},
// Navigate between steps
goToStep(step) {
this.currentStep = step
},
nextStep() {
this.currentStep += 1
},
previousStep() {
this.currentStep -= 1
},
// Clear session (for starting over)
clearSession() {
this.sessionData = {
basic_info: null,
attributes: null,
derived_values: null,
skills: [],
skills_meta: {
totalUsedPoints: 0,
selectedCategory: null
},
spells: [],
equipment: null
}
this.currentStep = 1
this.sessionId = null
this.error = null
}
}
})
@@ -1,66 +0,0 @@
import API from './api'
const defaultSystemLabel = (system = {}) => {
const code = system.code || ''
const name = system.name || ''
if (code && name) return `${code} (${name})`
return code || name || String(system.id ?? '')
}
export const normalizeSystem = (gs = {}) => ({
...gs,
id: gs.id ?? gs.ID ?? gs.Id ?? null,
code: gs.code ?? gs.Code ?? '',
name: gs.name ?? gs.Name ?? '',
description: gs.description ?? gs.Description ?? '',
is_active: gs.is_active ?? gs.IsActive ?? gs.isActive ?? false,
})
export const buildSystemOptions = (gameSystems = [], labelBuilder = defaultSystemLabel) =>
gameSystems.map(system => ({
id: system.id,
label: labelBuilder(system),
}))
// Alias retained for existing component imports
export const systemOptionsFor = buildSystemOptions
export const findSystemById = (gameSystems = [], id) => {
if (id === null || id === undefined) return null
return gameSystems.find(gs => gs.id === id) || null
}
export const findSystemIdByCode = (gameSystems = [], code) => {
if (!code) return null
const match = gameSystems.find(gs => gs.code === code)
return match ? match.id : null
}
export const buildGameSystemParams = system => {
if (!system) return {}
return {
game_system_id: system.id,
game_system: system.name,
}
}
export const getSystemCodeById = (gameSystems = [], systemId, fallback = '') => {
if (!systemId) return fallback
const sys = gameSystems.find(gs => gs.id === systemId)
return sys ? sys.code || fallback : fallback
}
// Alias retained for existing component imports
export const systemCodeFor = getSystemCodeById
export const getSourceCode = (sources = [], sourceId) => {
if (!sourceId) return ''
const source = sources.find(src => src.id === sourceId)
return source ? source.code || '' : ''
}
export const loadGameSystems = async () => {
const resp = await API.get('/api/maintenance/game-systems')
const systems = resp.data?.game_systems || []
return systems.map(normalizeSystem)
}
-11
View File
@@ -1,11 +0,0 @@
<template>
<AusruestungList :characterId="$route.params.characterId" />
</template>
<script>
import AusruestungList from '../components/AusruestungList.vue'
export default {
components: { AusruestungList },
}
</script>
-11
View File
@@ -1,11 +0,0 @@
<template>
<CharacterForm :characterId="$route.params.characterId" />
</template>
<script>
import CharacterForm from '../components/CharacterDetails.vue'
export default {
components: { CharacterForm },
}
</script>
+5 -4
View File
@@ -1,11 +1,12 @@
<template>
<CharacterList />
<div class="dashboard">
<h1>{{ $t('menu.Dashboard') }}</h1>
<p>Welcome to the application.</p>
</div>
</template>
<script>
import CharacterList from '../components/CharacterList.vue'
export default {
components: { CharacterList },
name: 'DashboardView',
}
</script>
-129
View File
@@ -1,129 +0,0 @@
<template>
<div class="fullwidth-page">
<div class="card" style="max-width: 600px; margin: 40px auto;">
<div class="page-header">
<h2>{{ $t('import.title') }}</h2>
</div>
<p class="text-muted">{{ $t('import.description') }}</p>
<form @submit.prevent="handleUpload">
<div class="form-group">
<label for="file_vtt">{{ $t('import.charVTT') }}</label>
<input
type="file"
id="file_vtt"
class="form-control"
@change="onFileChange($event, 1)"
accept=".json,.csv"
required
/>
<small class="form-text">{{ $t('import.vttHelp') }}</small>
</div>
<div class="form-group">
<label for="file_csv">{{ $t('import.charCSV') }}</label>
<input
type="file"
id="file_csv"
class="form-control"
@change="onFileChange($event, 2)"
accept=".json,.csv"
/>
<small class="form-text">{{ $t('import.csvHelp') }}</small>
</div>
<button
type="submit"
class="btn btn-primary"
style="width: 100%; margin-top: 10px;"
:disabled="!file_vtt"
>
{{ $t('import.upload') }}
</button>
</form>
<div v-if="error" class="badge badge-danger" style="width: 100%; margin-top: 15px; text-align: center; display: block;">
{{ error }}
</div>
<div v-if="success" class="badge badge-success" style="width: 100%; margin-top: 15px; text-align: center; display: block;">
{{ success }}
</div>
</div>
</div>
</template>
<script>
import API from "../utils/api";
export default {
data() {
return {
file_vtt: null,
file_csv: null,
error: "",
success: "",
};
},
computed: {
hasInvalidFileType() {
return !this.isValidFileType(this.file_vtt) || (this.file_csv && !this.isValidFileType(this.file_csv));
},
},
methods: {
onFileChange(event, fileNumber) {
const file = event.target.files[0];
if (fileNumber === 1) this.file_vtt = file;
if (fileNumber === 2) this.file_csv = file;
// Validate file type
if (!this.isValidFileType(file)) {
this.error = this.$t('import.invalidFileType');
return;
}
this.error = ""; // Clear any previous error
},
isValidFileType(file) {
if (!file) return false;
const allowedTypes = ["application/json", "text/csv"];
return allowedTypes.includes(file.type);
},
async handleUpload() {
try {
const formData = new FormData();
formData.append("file_vtt", this.file_vtt);
if (this.file_csv) formData.append("file_csv", this.file_csv);
const token = localStorage.getItem("token"); // Get token from storage
const response = await API.post("/api/importer/upload", formData, {
headers: {
"Content-Type": "multipart/form-data",
"Authorization": `Bearer ${token}`, // Include token in the header
},
});
this.success = this.$t('import.uploadSuccess');
this.error = "";
} catch (err) {
this.error = err.response?.data?.error || this.$t('import.uploadFailed');
this.success = "";
}
},
},
};
</script>
<style scoped>
.text-muted {
color: #6c757d;
margin-bottom: 20px;
}
.form-text {
display: block;
margin-top: 0.25rem;
color: #6c757d;
font-size: 0.875rem;
}
</style>
-11
View File
@@ -1,11 +0,0 @@
<template>
<Maintenance/>
</template>
<script>
import Maintenance from '../components/Maintenance.vue'
export default {
components: { Maintenance },
}
</script>