removed packages and views that where part of the original application
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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">×</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>
|
||||
@@ -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>
|
||||
@@ -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">×</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">×</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>
|
||||
@@ -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>
|
||||
@@ -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">×</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>
|
||||
@@ -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>
|
||||
@@ -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">×</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">×</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>
|
||||
@@ -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">×</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">×</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') }} </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>
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<template>
|
||||
<AusruestungList :characterId="$route.params.characterId" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AusruestungList from '../components/AusruestungList.vue'
|
||||
|
||||
export default {
|
||||
components: { AusruestungList },
|
||||
}
|
||||
</script>
|
||||
@@ -1,11 +0,0 @@
|
||||
<template>
|
||||
<CharacterForm :characterId="$route.params.characterId" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CharacterForm from '../components/CharacterDetails.vue'
|
||||
|
||||
export default {
|
||||
components: { CharacterForm },
|
||||
}
|
||||
</script>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -1,11 +0,0 @@
|
||||
<template>
|
||||
<Maintenance/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Maintenance from '../components/Maintenance.vue'
|
||||
|
||||
export default {
|
||||
components: { Maintenance },
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user