Some fixes and additions

This commit is contained in:
Torsten Schulz
2025-07-09 14:28:35 +02:00
parent 5029be81e9
commit fceea5b7fb
32 changed files with 4373 additions and 1294 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -8,13 +8,13 @@
"preview": "vite preview"
},
"dependencies": {
"@tinymce/tinymce-vue": "^6.0.1",
"@tiptap/starter-kit": "^2.14.0",
"@tiptap/vue-3": "^2.14.0",
"axios": "^1.7.2",
"date-fns": "^3.6.0",
"dotenv": "^16.4.5",
"mitt": "^3.0.1",
"socket.io-client": "^4.8.1",
"tinymce": "^7.3.0",
"vue": "~3.4.31",
"vue-i18n": "^10.0.0-beta.2",
"vue-multiselect": "^3.1.0",
@@ -30,6 +30,6 @@
"sass": "^1.77.8",
"stream-browserify": "^3.0.0",
"util": "^0.12.5",
"vite": "^5.4.4"
"vite": "^6.3.5"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

View File

@@ -1,56 +1,63 @@
<template>
<div class="simple-tabs">
<button
v-for="tab in tabs"
:key="tab.value"
:class="['simple-tab', { active: internalValue === tab.value }]"
@click="$emit('update:modelValue', tab.value)"
>
<slot name="label" :tab="tab">
{{ $t(tab.label) }}
</slot>
</button>
</div>
</template>
<script>
export default {
name: 'SimpleTabs',
props: {
tabs: {
type: Array,
required: true,
},
modelValue: {
type: [String, Number],
required: true,
},
<div class="simple-tabs">
<button
v-for="tab in tabs"
:key="tab.value"
:class="['simple-tab', { active: internalValue === tab.value }]"
@click="selectTab(tab.value)"
>
<slot name="label" :tab="tab">
{{ $t(tab.label) }}
</slot>
</button>
</div>
</template>
<script>
export default {
name: 'SimpleTabs',
props: {
tabs: {
type: Array,
required: true,
},
computed: {
internalValue() {
return this.modelValue;
}
modelValue: {
type: [String, Number],
required: true,
},
},
computed: {
internalValue() {
return this.modelValue;
}
},
methods: {
selectTab(value) {
// 1) v-model aktualisieren
this.$emit('update:modelValue', value);
// 2) zusätzliches change-Event
this.$emit('change', value);
}
};
</script>
<style scoped>
.simple-tabs {
display: flex;
margin-top: 1rem;
}
.simple-tab {
padding: 0.5rem 1rem;
background: #fff;
border: none;
cursor: pointer;
transition: background 0.2s;
}
.simple-tab.active {
background: #F9A22C;
color: #000;
}
</style>
};
</script>
<style scoped>
.simple-tabs {
display: flex;
margin-top: 1rem;
}
.simple-tab {
padding: 0.5rem 1rem;
background: #fff;
border: none;
cursor: pointer;
transition: background 0.2s;
}
.simple-tab.active {
background: #F9A22C;
color: #000;
}
</style>

View File

@@ -1,64 +1,91 @@
<template>
<div class="branch-selection">
<h3>{{ $t('falukant.branch.selection.title') }}</h3>
<div>
<FormattedDropdown
:options="branches"
:columns="branchColumns"
v-model="localSelectedBranch"
:placeholder="$t('falukant.branch.selection.placeholder')"
@input="updateSelectedBranch"
/>
</div>
<div>
<button @click="$emit('createBranch')">{{ $t('falukant.branch.actions.create') }}</button>
<button @click="$emit('upgradeBranch')" :disabled="!localSelectedBranch">
{{ $t('falukant.branch.actions.upgrade') }}
</button>
</div>
<div class="branch-selection">
<h3>{{ $t('falukant.branch.selection.title') }}</h3>
<div>
<FormattedDropdown
:options="branches"
:columns="branchColumns"
v-model="localSelectedBranch"
:placeholder="$t('falukant.branch.selection.placeholder')"
@input="updateSelectedBranch"
/>
</div>
</template>
<script>
import FormattedDropdown from '@/components/form/FormattedDropdown.vue';
export default {
name: "BranchSelection",
components: { FormattedDropdown },
props: {
branches: { type: Array, required: true },
selectedBranch: { type: Object, default: null },
<div>
<button @click="openCreateBranchDialog">
{{ $t('falukant.branch.actions.create') }}
</button>
<button
@click="$emit('upgradeBranch')"
:disabled="!localSelectedBranch"
>
{{ $t('falukant.branch.actions.upgrade') }}
</button>
</div>
</div>
<!-- Dialog-Komponente -->
<CreateBranchDialog
ref="createBranchDialog"
:regions="availableRegions"
@create-branch="handleCreateBranch"
/>
</template>
<script>
import FormattedDropdown from '@/components/form/FormattedDropdown.vue';
import CreateBranchDialog from '@/dialogues/falukant/CreateBranchDialog.vue';
export default {
name: "BranchSelection",
components: {
FormattedDropdown,
CreateBranchDialog,
},
props: {
branches: { type: Array, required: true },
selectedBranch: { type: Object, default: null },
},
data() {
return {
localSelectedBranch: this.selectedBranch,
branchColumns: [
{ field: "cityName", label: this.$t('falukant.branch.columns.city') },
{ field: "type", label: this.$t('falukant.branch.columns.type') },
],
};
},
watch: {
selectedBranch(newVal) {
this.localSelectedBranch = newVal;
},
data() {
return {
localSelectedBranch: this.selectedBranch,
branchColumns: [
{ field: "cityName", label: this.$t('falukant.branch.columns.city') },
{ field: "type", label: this.$t('falukant.branch.columns.type') },
],
};
},
methods: {
updateSelectedBranch(value) {
this.$emit('branchSelected', value);
},
watch: {
selectedBranch(newVal) {
this.localSelectedBranch = newVal;
},
openCreateBranchDialog() {
this.$refs.createBranchDialog.open();
},
methods: {
updateSelectedBranch(value) {
this.$emit('branchSelected', value);
},
handleCreateBranch() {
// wird ausgelöst, sobald der Dialog onConfirm erfolgreich abschließt
this.$emit('createBranch');
},
};
</script>
<style scoped>
.branch-selection {
border: 1px solid #ccc;
margin: 10px 0;
border-radius: 4px;
padding: 10px;
}
button {
margin: 5px;
}
</style>
},
};
</script>
<style scoped>
.branch-selection {
border: 1px solid #ccc;
margin: 10px 0;
border-radius: 4px;
padding: 10px;
}
button {
margin: 5px;
}
</style>

View File

@@ -6,7 +6,7 @@
<p>{{ contact.message }}</p>
</div>
<div class="editor-container">
<Editor v-model="answer" :init="tinymceInitOptions" :api-key="apiKey" />
<EditorContent :editor="editor" class="editor" />
</div>
</DialogWidget>
@@ -19,8 +19,8 @@
</template>
<script>
import { ref, onBeforeUnmount } from 'vue'
import Editor from '@tinymce/tinymce-vue'
import { Editor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import apiClient from '@/utils/axios.js'
import DialogWidget from '@/components/DialogWidget.vue'
@@ -28,29 +28,15 @@ export default {
name: 'AnswerContact',
components: {
DialogWidget,
Editor,
EditorContent,
},
data() {
return {
apiKey: import.meta.env.VITE_TINYMCE_API_KEY,
dialog: null,
errorDialog: null,
contact: null,
answer: '',
errorMessage: '',
tinymceInitOptions: {
height: 300,
menubar: false,
plugins: [
'advlist autolink lists link image charmap print preview anchor',
'searchreplace visualblocks code fullscreen',
'insertdatetime media table paste code help wordcount'
],
toolbar:
'undo redo cut copy paste | bold italic forecolor fontfamily fontsize | \
alignleft aligncenter alignright alignjustify | \
bullist numlist outdent indent | removeformat | help'
},
editor: null,
buttons: [
{ text: 'OK', action: this.sendAnswer },
{ text: 'Cancel', action: this.closeDialog }
@@ -64,24 +50,25 @@ export default {
open(contactData) {
this.contact = contactData;
this.dialog.open();
this.answer = '';
if (this.editor) this.editor.commands.setContent('');
},
closeDialog() {
this.dialog.close();
this.answer = '';
if (this.editor) this.editor.commands.clearContent();
},
closeErrorDialog() {
this.errorDialog.close();
},
async sendAnswer() {
const answer = this.editor ? this.editor.getHTML() : '';
try {
await apiClient.post('/api/admin/contacts/answer', {
id: this.contact.id,
answer: this.answer,
answer,
});
this.dialog.close();
this.$emit('refresh');
this.answer = '';
if (this.editor) this.editor.commands.clearContent();
} catch (error) {
const errorText = error.response?.data?.error || 'An unexpected error occurred.';
this.errorMessage = errorText;
@@ -92,9 +79,16 @@ export default {
mounted() {
this.dialog = this.$refs.dialog;
this.errorDialog = this.$refs.errorDialog;
this.editor = new Editor({
extensions: [StarterKit],
content: '',
});
},
beforeUnmount() {
// Aufräumarbeiten falls nötig
if (this.editor) {
this.editor.destroy();
}
}
}
</script>
@@ -106,5 +100,13 @@ export default {
.editor-container {
margin-top: 20px;
border: 1px solid #ccc;
padding: 10px;
min-height: 200px;
}
.editor {
min-height: 150px;
outline: none;
}
</style>

View File

@@ -0,0 +1,342 @@
<template>
<DialogWidget
ref="dialog"
name="create-branch"
:title="$t('falukant.branch.actions.create')"
icon="branch.png"
showClose
:buttons="dialogButtons"
@close="onClose"
>
<div class="create-branch-form">
<div class="map-wrapper">
<!-- linke Spalte: Karte + Regionen + Dev-Draw -->
<div class="map-container">
<img
ref="mapImage"
src="/images/falukant/map.png"
class="map"
@mousedown="onMouseDown"
@mousemove="onMouseMove"
@mouseup="onMouseUp"
@mouseleave="onMouseUp"
@dragstart.prevent
/>
<div
v-for="city in cities"
:key="city.name"
class="city-region"
:class="city.branches.length > 0 ? 'has-branch' : 'clickable'"
:style="{
top: city.map.y + 'px',
left: city.map.x + 'px',
width: city.map.w + 'px',
height: city.map.h + 'px'
}"
@click="city.branches.length === 0 && onCityClick(city)"
:title="city.name"
></div>
<div
v-if="devMode && rect"
class="dev-rect"
:style="{
top: rect.y + 'px',
left: rect.x + 'px',
width: rect.width + 'px',
height: rect.height + 'px'
}"
></div>
</div>
<!-- rechte Spalte: Dev-Info + Auswahl -->
<div class="sidebar">
<div v-if="devMode" class="dev-info">
<span class="dev-badge">DEV MODE</span>
<span v-if="rect" class="dev-label-outside">
{{ rect.x }},{{ rect.y }} {{ rect.width }}×{{ rect.height }}
</span>
</div>
<div v-if="selectedRegion" class="selected-region-wrapper">
<div class="selected-region">
{{ $t('falukant.branch.selection.selected') }}:
<strong>{{ selectedRegion.name }}</strong>
</div>
<label class="form-label">
{{ $t('falukant.branch.columns.type') }}
<select v-model="selectedType" class="form-control">
<option
v-for="type in branchTypes"
:key="type.id"
:value="type.id"
>
{{ $t(`falukant.branch.types.${type.labelTr}`) }}
({{ formatCost(computeBranchCost(type)) }})
</option>
</select>
</label>
</div>
</div>
</div>
</div>
</DialogWidget>
</template>
<script>
import DialogWidget from '@/components/DialogWidget.vue';
import apiClient from '@/utils/axios.js';
export default {
name: 'CreateBranchDialog',
components: { DialogWidget },
data() {
return {
cities: [],
branchTypes: [],
selectedRegion: null,
selectedType: null,
devMode: false,
rect: null,
startX: null,
startY: null,
currentX: 0,
currentY: 0,
};
},
computed: {
dialogButtons() {
return [
{ text: this.$t('Cancel'), action: this.close },
{ text: this.$t('falukant.branch.actions.create'), action: this.onConfirm },
];
},
},
async mounted() {
window.addEventListener('keydown', this.onKeyDown);
await Promise.all([
this.loadCities(),
this.loadBranchTypes(),
]);
this.selectedType = this.branchTypes.length ? this.branchTypes[0].id : null;
},
beforeDestroy() {
window.removeEventListener('keydown', this.onKeyDown);
},
methods: {
open() {
this.$refs.dialog.open();
},
close() {
this.$refs.dialog.close();
},
async onConfirm() {
if (!this.selectedRegion || !this.selectedType) return;
try {
await apiClient.post('/api/falukant/branches', {
cityId: this.selectedRegion.id,
branchTypeId: this.selectedType,
});
this.$emit('create-branch');
this.close();
} catch (e) {
}
},
onClose() {
this.close();
this.$emit('close');
},
onKeyDown(e) {
if (e.ctrlKey && e.altKey && e.code === 'KeyD') {
this.devMode = !this.devMode;
if (!this.devMode) this.rect = null;
}
},
onMouseDown(e) {
if (!this.devMode) return;
const bounds = this.$refs.mapImage.getBoundingClientRect();
this.startX = e.clientX - bounds.left;
this.startY = e.clientY - bounds.top;
this.currentX = this.startX;
this.currentY = this.startY;
this.updateRect();
e.preventDefault();
},
onMouseMove(e) {
if (!this.devMode || this.startX === null) return;
const bounds = this.$refs.mapImage.getBoundingClientRect();
this.currentX = e.clientX - bounds.left;
this.currentY = e.clientY - bounds.top;
this.updateRect();
},
onMouseUp() {
if (!this.devMode) return;
this.startX = null;
this.startY = null;
},
updateRect() {
if (this.startX === null || this.startY === null) return;
const x = Math.min(this.startX, this.currentX);
const y = Math.min(this.startY, this.currentY);
const width = Math.abs(this.currentX - this.startX);
const height = Math.abs(this.currentY - this.startY);
this.rect = {
x: Math.round(x),
y: Math.round(y),
width: Math.round(width),
height: Math.round(height),
};
},
async loadCities() {
const { data } = await apiClient.get('/api/falukant/cities');
this.cities = data;
},
onCityClick(city) {
this.selectedRegion = city;
},
async loadBranchTypes() {
const { data } = await apiClient.get('/api/falukant/branches/types');
this.branchTypes = data;
},
computeBranchCost(type) {
const total = this.cities.reduce((sum, city) => sum + city.branches.length, 0);
const factor = Math.pow(Math.max(total, 1), 1.2);
const raw = type.baseCost * factor;
return Math.round(raw * 100) / 100;
},
formatCost(value) {
return new Intl.NumberFormat(navigator.language, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
},
},
};
</script>
<style scoped>
.create-branch-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.map-wrapper {
display: flex;
align-items: flex-start;
gap: 1rem;
}
.map-container {
position: relative;
width: fit-content;
}
.map {
max-width: 500px;
max-height: 400px;
user-select: none;
cursor: crosshair;
}
.city-region {
position: absolute;
}
.city-region.clickable {
cursor: pointer;
background: rgba(0, 0, 255, 0.2);
}
.city-region.has-branch {
cursor: default;
background: transparent;
border: 2px solid #00ff00;
}
.dev-rect {
position: absolute;
border: 2px dashed red;
pointer-events: none;
}
.sidebar {
display: flex;
flex-direction: column;
gap: 1rem;
min-width: 200px;
}
.dev-info {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.dev-badge {
background: rgba(255, 0, 0, 0.7);
color: white;
padding: 2px 6px;
font-size: 0.75rem;
border-radius: 3px;
pointer-events: none;
}
.dev-label-outside {
background: rgba(0, 0, 0, 0.7);
color: #fff;
font-size: 0.75rem;
padding: 2px 4px;
border-radius: 2px;
pointer-events: none;
}
.selected-region-wrapper {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.selected-region {
font-weight: 600;
}
.form-label {
font-weight: 600;
margin-bottom: 0.25rem;
}
.form-control {
padding: 0.5rem;
font-size: 1rem;
border: 1px solid #ccc;
border-radius: 4px;
}
</style>

View File

@@ -51,8 +51,8 @@ export default {
selectedProposal: null,
products: [],
buttons: [
{ text: 'Einstellen', action: this.hireDirector },
{ text: 'Abbrechen', action: 'close' },
{ text: this.$t('falukant.newdirector.hire'), action: this.hireDirector },
{ text: this.$t('Cancel'), action: 'close' },
],
};
},

View File

@@ -55,8 +55,7 @@
<img :src="imagePreview" alt="Image Preview"
style="max-width: 100px; max-height: 100px;" />
</div>
<editor v-model="newEntryContent" :init="tinymceInitOptions" :api-key="apiKey"
tinymce-script-src="/tinymce/tinymce.min.js"></editor>
<EditorContent :editor="editor" class="editor" />
</div>
<button @click="submitGuestbookEntry">{{ $t('socialnetwork.profile.guestbook.submit')
}}</button>
@@ -95,14 +94,15 @@
import DialogWidget from '@/components/DialogWidget.vue';
import apiClient from '@/utils/axios.js';
import FolderItem from '../../components/FolderItem.vue';
import TinyMCEEditor from '@tinymce/tinymce-vue';
import { Editor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
export default {
name: 'UserProfileDialog',
components: {
DialogWidget,
FolderItem,
editor: TinyMCEEditor,
EditorContent,
},
data() {
return {
@@ -126,27 +126,20 @@ export default {
{ name: 'guestbook', label: this.$t('socialnetwork.profile.tab.guestbook') }
],
apiKey: import.meta.env.VITE_TINYMCE_API_KEY,
tinymceInitOptions: {
script_url: '/tinymce/tinymce.min.js',
height: 300,
menubar: true,
plugins: [
'lists', 'link',
'searchreplace', 'visualblocks', 'code',
'insertdatetime', 'table'
],
toolbar:
'undo redo cut copy paste | bold italic forecolor backcolor fontfamily fontsize| \
alignleft aligncenter alignright alignjustify | \
bullist numlist outdent indent | removeformat | link visualblocks code',
contextmenu: 'link image table',
menubar: 'edit format table',
promotion: false,
},
editor: null,
hasSendFriendshipRequest: false,
friendshipState: 'none',
};
},
mounted: async function () {
this.editor = new Editor({
extensions: [StarterKit],
content: '',
});
},
beforeUnmount: function () {
if (this.editor) this.editor.destroy();
},
methods: {
open() {
this.$refs.dialog.open();

View File

@@ -4,7 +4,8 @@
"age": "Alter",
"wealth": "Vermögen",
"health": "Gesundheit",
"events": "Ereignisse"
"events": "Ereignisse",
"relationship": "Beziehung"
},
"health": {
"amazing": "Super",
@@ -97,7 +98,8 @@
"selection": {
"title": "Niederlassungsauswahl",
"selected": "Ausgewählte Niederlassung",
"placeholder": "Noch keine Niederlassung ausgewählt"
"placeholder": "Noch keine Niederlassung ausgewählt",
"selectedcity": "Ausgewählte Stadt"
},
"actions": {
"create": "Neue Niederlassung erstellen",
@@ -223,7 +225,6 @@
"3": "Mittel",
"4": "Hoch",
"5": "Sehr hoch"
},
"mood": "Stimmung",
"progress": "Zuneigung",
@@ -353,19 +354,32 @@
"Fine Wine": "Feiner Wein",
"Artisan Chocolate": "Kunsthandwerkliche Schokolade",
"Pearl Necklace": "Perlenanhänger",
"Rare Painting": "Seltenes Gemälde",
"Rare Painting": "Seltenes Gemälde",
"Silver Watch": "Silberuhr",
"Cat": "Katze",
"Dog": "Hund",
"Cat": "Katze",
"Dog": "Hund",
"Horse": "Pferd"
},
"mood": {
"happy": "Glücklich",
"sad": "Traurig",
"angry": "Wütend",
"scared": "Verängstigt",
"surprised": "Überrascht",
"normal": "Normal"
"nervous": "Nervös",
"excited": "Aufgeregt",
"bored": "Gelangweilt",
"fearful": "Ängstlich",
"confident": "Selbstbewusst",
"curious": "Neugierig",
"hopeful": "Hoffnungsvoll",
"frustrated": "Frustriert",
"lonely": "Einsam",
"grateful": "Dankbar",
"jealous": "Eifersüchtig",
"guilty": "Schuldig",
"apathetic": "Apathisch",
"relieved": "Erleichtert",
"proud": "Stolz",
"ashamed": "Beschämt"
},
"character": {
"brave": "Mutig",
@@ -595,6 +609,72 @@
"barber": "Barbier"
},
"choose": "Bitte auswählen"
},
"politics": {
"title": "Politik",
"tabs": {
"current": "Aktuelle Position",
"upcoming": "Anstehende Neuwahl-Positionen",
"elections": "Wahlen"
},
"current": {
"office": "Amt",
"region": "Region",
"termEnds": "Läuft ab am",
"income": "Einkommen",
"none": "Keine aktuelle Position vorhanden.",
"holder": "Inhaber"
},
"open": {
"office": "Amt",
"region": "Region",
"date": "Datum",
"candidacy": "Kandidatur",
"none": "Keine offenen Positionen."
},
"upcoming": {
"office": "Amt",
"region": "Region",
"postDate": "Datum",
"none": "Keine anstehenden Positionen."
},
"elections": {
"office": "Amt",
"region": "Region",
"date": "Datum",
"posts": "Zu besetzende Posten",
"none": "Keine Wahlen vorhanden.",
"choose": "Kandidaten",
"vote": "Stimme abgeben",
"voteAll": "Alle Stimmen abgeben",
"candidates": "Kandidaten",
"action": "Aktion"
},
"offices": {
"chancellor": "Kanzler",
"minister": "Minister",
"ministry-helper": "Ministerhelfer",
"governor": "Gouverneur",
"super-state-administrator": "Oberstaatsverwalter",
"state-administrator": "Staatsverwalter",
"ruler-consultant": "Berater des Herrschers",
"territorial-council-speaker": "Sprecher des Territorialrats",
"territorial-council": "Territorialrat",
"hangman": "Henker",
"treasurer": "Schatzmeister",
"sheriff": "Sheriff",
"taxman": "Steuereintreiber",
"bailif": "Gerichtsdiener",
"judge": "Richter",
"village-major": "Dorfvorsteher",
"master-builder": "Baumeister",
"mayor": "Bürgermeister",
"town-clerk": "Stadtschreiber",
"beadle": "Schulze",
"council": "Ratsherr",
"councillor": "Stadtrat",
"assessor": "Schätzer"
}
}
}
}

View File

@@ -11,6 +11,7 @@ import EducationView from '../views/falukant/EducationView.vue';
import BankView from '../views/falukant/BankView.vue';
import DirectorView from '../views/falukant/DirectorView.vue';
import HealthView from '../views/falukant/HealthView.vue';
import PoliticsView from '../views/falukant/PoliticsView.vue';
const falukantRoutes = [
{
@@ -91,6 +92,12 @@ const falukantRoutes = [
component: HealthView,
meta: { requiresAuth: true }
},
{
path: '/falukant/politics',
name: 'PoliticsView',
component: PoliticsView,
meta: { requiresAuth: true }
},
];
export default falukantRoutes;

View File

@@ -1,236 +1,291 @@
<template>
<div class="contenthidden">
<StatusBar ref="statusBar" />
<div class="contentscroll">
<h2>{{ $t('falukant.branch.title') }}</h2>
<BranchSelection :branches="branches" :selectedBranch="selectedBranch" @branchSelected="onBranchSelected"
@createBranch="createBranch" @upgradeBranch="upgradeBranch" ref="branchSelection" />
<DirectorInfo v-if="selectedBranch" :branchId="selectedBranch.id" ref="directorInfo" />
<SaleSection v-if="selectedBranch" :branchId="selectedBranch.id" ref="saleSection" />
<ProductionSection v-if="selectedBranch" :branchId="selectedBranch.id" :products="products"
ref="productionSection" />
<StorageSection v-if="selectedBranch" :branchId="selectedBranch.id" ref="storageSection" />
<RevenueSection v-if="selectedBranch" :products="products"
:calculateProductRevenue="calculateProductRevenue" :calculateProductProfit="calculateProductProfit"
ref="revenueSection" />
</div>
<StatusBar ref="statusBar" />
<div class="contentscroll">
<h2>{{ $t('falukant.branch.title') }}</h2>
<BranchSelection
:branches="branches"
:selectedBranch="selectedBranch"
@branchSelected="onBranchSelected"
@createBranch="createBranch"
@upgradeBranch="upgradeBranch"
ref="branchSelection"
/>
<DirectorInfo
v-if="selectedBranch"
:branchId="selectedBranch.id"
ref="directorInfo"
/>
<SaleSection
v-if="selectedBranch"
:branchId="selectedBranch.id"
ref="saleSection"
/>
<ProductionSection
v-if="selectedBranch"
:branchId="selectedBranch.id"
:products="products"
ref="productionSection"
/>
<StorageSection
v-if="selectedBranch"
:branchId="selectedBranch.id"
ref="storageSection"
/>
<RevenueSection
v-if="selectedBranch"
:products="products"
:calculateProductRevenue="calculateProductRevenue"
:calculateProductProfit="calculateProductProfit"
ref="revenueSection"
/>
</div>
</div>
</template>
<script>
import StatusBar from '@/components/falukant/StatusBar.vue';
import BranchSelection from '@/components/falukant/BranchSelection.vue';
import DirectorInfo from '@/components/falukant/DirectorInfo.vue';
import SaleSection from '@/components/falukant/SaleSection.vue';
import ProductionSection from '@/components/falukant/ProductionSection.vue';
import StorageSection from '@/components/falukant/StorageSection.vue';
import RevenueSection from '@/components/falukant/RevenueSection.vue';
import apiClient from '@/utils/axios.js';
import { mapState } from 'vuex';
export default {
</template>
<script>
import StatusBar from '@/components/falukant/StatusBar.vue';
import BranchSelection from '@/components/falukant/BranchSelection.vue';
import DirectorInfo from '@/components/falukant/DirectorInfo.vue';
import SaleSection from '@/components/falukant/SaleSection.vue';
import ProductionSection from '@/components/falukant/ProductionSection.vue';
import StorageSection from '@/components/falukant/StorageSection.vue';
import RevenueSection from '@/components/falukant/RevenueSection.vue';
import apiClient from '@/utils/axios.js';
import { mapState } from 'vuex';
export default {
name: "BranchView",
components: {
StatusBar,
BranchSelection,
DirectorInfo,
SaleSection,
ProductionSection,
StorageSection,
RevenueSection,
StatusBar,
BranchSelection,
DirectorInfo,
SaleSection,
ProductionSection,
StorageSection,
RevenueSection,
},
data() {
return {
branches: [],
selectedBranch: null,
products: [],
};
return {
branches: [],
selectedBranch: null,
products: [],
};
},
computed: {
...mapState(['socket', 'daemonSocket']),
...mapState(['socket', 'daemonSocket']),
},
async mounted() {
await this.loadBranches();
const branchId = this.$route.params.branchId;
await this.loadProducts();
if (branchId) {
this.selectedBranch =
this.branches.find(branch => branch.id === parseInt(branchId)) || null;
} else {
this.selectMainBranch();
await this.loadBranches();
const branchId = this.$route.params.branchId;
await this.loadProducts();
if (branchId) {
this.selectedBranch = this.branches.find(
b => b.id === parseInt(branchId, 10)
) || null;
} else {
this.selectMainBranch();
}
// Daemon-Socket
if (this.daemonSocket) {
this.daemonSocket.addEventListener('message', this.handleDaemonMessage);
}
// Live-Socket-Events
[
"production_ready",
"stock_change",
"price_update",
"director_death",
"production_started",
"selled_items",
"falukantUpdateStatus",
"falukantBranchUpdate",
"knowledge_update"
].forEach(eventName => {
if (this.socket) {
this.socket.on(eventName, data => this.handleEvent({ event: eventName, ...data }));
}
const events = [
"production_ready",
"stock_change",
"price_update",
"director_death",
"production_started",
"selled_items",
"falukantUpdateStatus",
"falukantBranchUpdate",
];
if (this.daemonSocket) {
this.daemonSocket.addEventListener('message', this.handleDaemonMessage);
}
events.forEach(eventName => {
if (this.socket) {
this.socket.on(eventName, (data) => {
this.handleEvent({ event: eventName, ...data });
});
}
});
});
},
beforeUnmount() {
const events = [
"production_ready",
"stock_change",
"price_update",
"director_death",
"production_started",
"selled_items",
"falukantUpdateStatus",
"falukantBranchUpdate",
];
events.forEach(eventName => {
if (this.socket) {
this.socket.off(eventName, this.handleEvent);
}
});
if (this.daemonSocket) {
this.daemonSocket.removeEventListener('message', this.handleDaemonMessage);
[
"production_ready",
"stock_change",
"price_update",
"director_death",
"production_started",
"selled_items",
"falukantUpdateStatus",
"falukantBranchUpdate",
"knowledge_update"
].forEach(eventName => {
if (this.socket) {
this.socket.off(eventName, this.handleEvent);
}
});
if (this.daemonSocket) {
this.daemonSocket.removeEventListener('message', this.handleDaemonMessage);
}
},
methods: {
async loadBranches() {
try {
const result = await apiClient.get('/api/falukant/branches');
this.branches = result.data.map(branch => ({
id: branch.id,
cityName: branch.region.name,
type: this.$t(`falukant.branch.types.${branch.branchType.labelTr}`),
isMainBranch: branch.isMainBranch,
}));
if (!this.selectedBranch) {
this.selectMainBranch();
}
} catch (error) {
console.error('Error loading branches:', error);
}
},
async loadProducts() {
try {
const productsResult = await apiClient.get('/api/falukant/products');
this.products = productsResult.data;
} catch (error) {
console.error('Error loading products:', error);
}
},
handleEvent(event) {
if (event.type === 'branchUpdated') {
this.loadBranches();
}
},
handleDaemonMessage(event) {
const message = JSON.parse(event.data);
},
selectMainBranch() {
const main = this.branches.find(b => b.isMainBranch) || null;
if (main && main !== this.selectedBranch) {
this.selectedBranch = main;
}
},
onBranchSelected(newBranch) {
this.selectedBranch = newBranch;
},
createBranch() {
alert(this.$t('falukant.branch.actions.createAlert'));
},
upgradeBranch() {
if (this.selectedBranch) {
alert(this.$t('falukant.branch.actions.upgradeAlert', { branchId: this.selectedBranch.id }));
}
},
calculateProductRevenue(product) {
if (!product.knowledges || product.knowledges.length === 0) {
return { absolute: 0, perMinute: 0 };
}
const knowledgeFactor = product.knowledges[0].knowledge || 0;
const maxPrice = product.sellCost;
const minPrice = maxPrice * 0.6;
const revenuePerUnit = minPrice + (maxPrice - minPrice) * (knowledgeFactor / 100);
const perMinute = product.productionTime > 0 ? revenuePerUnit / product.productionTime : 0;
return {
absolute: revenuePerUnit.toFixed(2),
perMinute: perMinute.toFixed(2),
};
},
calculateProductProfit(product) {
const { absolute: revenueAbsoluteStr, perMinute: revenuePerMinuteStr } = this.calculateProductRevenue(product);
const revenueAbsolute = parseFloat(revenueAbsoluteStr);
const costPerUnit = 6 * product.category;
const profitAbsolute = revenueAbsolute - costPerUnit;
const costPerMinute = product.productionTime > 0 ? costPerUnit / product.productionTime : 0;
const profitPerMinute = parseFloat(revenuePerMinuteStr) - costPerMinute;
return {
absolute: profitAbsolute.toFixed(2),
perMinute: profitPerMinute.toFixed(2),
};
},
// Gemeinsamer Event-Handler für socket-Events
handleEvent(eventData) {
switch (eventData.event || eventData) {
case 'production_ready':
this.$refs.productionSection && this.$refs.productionSection.loadProductions();
this.$refs.storageSection && this.$refs.storageSection.loadStorageData();
this.$refs.saleSection && this.$refs.saleSection.loadInventory();
break;
case 'stock_change':
this.$refs.storageSection && this.$refs.storageSection.loadStorageData();
this.$refs.saleSection && this.$refs.saleSection.loadInventory();
break;
case 'price_update':
this.$refs.revenueSection && this.$refs.revenueSection.refresh && this.$refs.revenueSection.refresh();
break;
case 'director_death':
this.$refs.directorInfo && this.$refs.directorInfo.loadDirector();
break;
case 'production_started':
this.$refs.productionSection && this.$refs.productionSection.loadProductions();
break;
case 'selled_items':
this.$refs.saleSection && this.$refs.saleSection.loadInventory();
this.$refs.storageSection && this.$refs.storageSection.loadStorageData();
break;
case 'falukantUpdateStatus':
case 'falukantBranchUpdate':
this.$refs.statusBar && this.$refs.statusBar.updateStatus && this.$refs.statusBar.updateStatus(eventData);
this.$refs.productionSection && this.$refs.productionSection.loadProductions();
this.$refs.storageSection && this.$refs.storageSection.loadStorageData();
this.$refs.saleSection && this.$refs.saleSection.loadInventory();
break;
case 'knowledge_update':
this.loadProducts();
this.$refs.revenueSection.products = this.products;
break;
default:
console.log('Unhandled event:', eventData);
}
},
handleDaemonMessage(event) {
if (event.data === "ping") return;
try {
const message = JSON.parse(event.data);
this.handleEvent(message);
} catch (error) {
console.error('Error processing daemon message in BranchView:', error);
}
},
async loadBranches() {
try {
const result = await apiClient.get('/api/falukant/branches');
this.branches = result.data.map(branch => ({
id: branch.id,
cityName: branch.region.name,
type: this.$t(`falukant.branch.types.${branch.branchType.labelTr}`),
isMainBranch: branch.isMainBranch,
}));
if (!this.selectedBranch) {
this.selectMainBranch();
}
} catch (error) {
console.error('Error loading branches:', error);
}
},
async loadProducts() {
try {
const productsResult = await apiClient.get('/api/falukant/products');
this.products = productsResult.data;
} catch (error) {
console.error('Error loading products:', error);
}
},
onBranchSelected(newBranch) {
this.selectedBranch = newBranch;
},
async createBranch() {
// Nach erfolgreichem Dialog-Event: neu laden
await this.loadBranches();
},
upgradeBranch() {
if (this.selectedBranch) {
alert(
this.$t(
'falukant.branch.actions.upgradeAlert',
{ branchId: this.selectedBranch.id }
)
);
}
},
selectMainBranch() {
const main = this.branches.find(b => b.isMainBranch) || null;
if (main && main !== this.selectedBranch) {
this.selectedBranch = main;
}
},
calculateProductRevenue(product) {
if (!product.knowledges || product.knowledges.length === 0) {
return { absolute: 0, perMinute: 0 };
}
const knowledgeFactor = product.knowledges[0].knowledge || 0;
const maxPrice = product.sellCost;
const minPrice = maxPrice * 0.6;
const revenuePerUnit = minPrice + (maxPrice - minPrice) * (knowledgeFactor / 100);
const perMinute = product.productionTime > 0
? revenuePerUnit / product.productionTime
: 0;
return {
absolute: revenuePerUnit.toFixed(2),
perMinute: perMinute.toFixed(2),
};
},
calculateProductProfit(product) {
const { absolute: revenueAbsoluteStr, perMinute: revenuePerMinuteStr }
= this.calculateProductRevenue(product);
const revenueAbsolute = parseFloat(revenueAbsoluteStr);
const costPerUnit = 6 * product.category;
const profitAbsolute = revenueAbsolute - costPerUnit;
const costPerMinute = product.productionTime > 0
? costPerUnit / product.productionTime
: 0;
const profitPerMinute = parseFloat(revenuePerMinuteStr) - costPerMinute;
return {
absolute: profitAbsolute.toFixed(2),
perMinute: profitPerMinute.toFixed(2),
};
},
handleEvent(eventData) {
switch (eventData.event) {
case 'production_ready':
this.$refs.productionSection?.loadProductions();
this.$refs.storageSection ?.loadStorageData();
this.$refs.saleSection ?.loadInventory();
break;
case 'stock_change':
this.$refs.storageSection ?.loadStorageData();
this.$refs.saleSection ?.loadInventory();
break;
case 'price_update':
this.$refs.revenueSection?.refresh();
break;
case 'director_death':
this.$refs.directorInfo?.loadDirector();
break;
case 'production_started':
this.$refs.productionSection?.loadProductions();
break;
case 'selled_items':
this.$refs.saleSection ?.loadInventory();
this.$refs.storageSection?.loadStorageData();
break;
case 'falukantUpdateStatus':
case 'falukantBranchUpdate':
this.$refs.statusBar?.fetchStatus();
this.$refs.productionSection?.loadProductions();
this.$refs.storageSection ?.loadStorageData();
this.$refs.saleSection ?.loadInventory();
break;
case 'knowledge_update':
this.loadProducts();
this.$refs.revenueSection.products = this.products;
break;
default:
console.log('Unhandled event:', eventData);
}
},
handleDaemonMessage(event) {
if (event.data === 'ping') return;
try {
const message = JSON.parse(event.data);
this.handleEvent(message);
} catch (error) {
console.error('Error processing daemon message:', error);
}
},
},
};
</script>
<style scoped lang="scss">
h2 {
};
</script>
<style scoped lang="scss">
h2 {
padding-top: 20px;
}
</style>
}
</style>

View File

@@ -43,12 +43,13 @@
</tr>
<tr v-if="relationships[0].relationshipType === 'engaged'" colspan="2">
<button @click="jumpToPartyForm">{{ $t('falukant.family.spouse.jumpToPartyForm')
}}</button>
}}</button>
</tr>
</table>
<ul>
<li v-for="characteristic in relationships[0].character2.characterTrait"
:key="characteristic.id">{{ $t(`falukant.character.${characteristic.tr}`) }}</li>
<li v-for="trait in relationships[0].character2.traits" :key="trait.id">
{{ $t(`falukant.character.${trait.tr}`) }}
</li>
</ul>
</div>
<div v-if="relationships[0].relationshipType === 'wooing'">
@@ -64,16 +65,18 @@
</thead>
<tbody>
<tr v-for="gift in gifts" :key="gift.id">
<td><input type="radio" name="gift" :value="gift.id" v-model="selectedGiftId"></td>
<td>
<input type="radio" name="gift" :value="gift.id" v-model="selectedGiftId" />
</td>
<td>{{ $t(`falukant.gifts.${gift.name}`) }}</td>
<td>{{ $t(`falukant.family.spouse.giftAffect.${getEffect(gift)}`) }}</td>
<td>{{ getEffect(gift) }}</td>
<td>{{ formatCost(gift.cost) }}</td>
</tr>
</tbody>
</table>
<div>
<button @click="sendGift" class="button">{{ $t('falukant.family.spouse.wooing.sendGift')
}}</button>
}}</button>
</div>
</div>
</div>
@@ -93,7 +96,7 @@
v-model="selectedProposalId"></td>
<td>{{
$t(`falukant.titles.${proposal.proposedCharacterGender}.${proposal.proposedCharacterNobleTitle}`)
}} {{ proposal.proposedCharacterName }}</td>
}} {{ proposal.proposedCharacterName }}</td>
<td>{{ proposal.proposedCharacterAge }}</td>
<td>{{ formatCost(proposal.cost) }}</td>
</tr>
@@ -123,7 +126,8 @@
{{ child.name }}
</td>
<td v-else>
<button @click="jumpToChurchForm">{{ $t('falukant.family.children.baptism') }}</button>
<button @click="jumpToChurchForm">{{ $t('falukant.family.children.baptism')
}}</button>
</td>
<td>{{ child.age }}</td>
<td>
@@ -308,12 +312,38 @@ export default {
});
},
handleDaemonMessage() {
handleDaemonMessage(event) {
if (event.data === 'ping') {
return;
}
const message = JSON.parse(event.data);
if (message.event === 'children_update') {
this.loadFamilyData();
}
}
},
getEffect(gift) {
// aktueller Partner
const partner = this.relationships[0].character2;
// seine aktuelle Mood-ID
const moodId = partner.mood?.id ?? partner.mood_id;
// 1) Mood-Eintrag finden
const moodEntry = gift.moodsAffects.find(ma => ma.mood_id === moodId);
const moodValue = moodEntry ? moodEntry.suitability : 0;
// 2) Trait-Einträge matchen
let highestTraitValue = 0;
for (const trait of partner.traits) {
const charEntry = gift.charactersAffects.find(ca => ca.trait_id === trait.id);
if (charEntry && charEntry.suitability > highestTraitValue) {
highestTraitValue = charEntry.suitability;
}
}
// Durchschnitt, gerundet
return Math.round((moodValue + highestTraitValue) / 2);
},
}
}
</script>

View File

@@ -13,14 +13,15 @@
</tr>
<tr>
<td>{{ $t('falukant.overview.metadata.nobleTitle') }}</td>
<td>{{ $t('falukant.titles.' + falukantUser?.character.gender + '.' + falukantUser?.character.nobleTitle.labelTr) }}</td>
<td>{{ $t('falukant.titles.' + falukantUser?.character.gender + '.' +
falukantUser?.character.nobleTitle.labelTr) }}</td>
</tr>
<tr>
<td>{{ $t('falukant.overview.metadata.money') }}</td>
<td>
{{ moneyValue != null
? moneyValue.toLocaleString(locale, { style: 'currency', currency: 'EUR' })
: '---' }}
: '---' }}
</td>
</tr>
<tr>
@@ -170,17 +171,24 @@ export default {
};
},
getHouseStyle() {
if (!this.falukantUser) return {};
console.log(this.falukantUser);
if (!this.falukantUser || !this.falukantUser.userHouse?.houseType) return {};
const imageUrl = '/images/falukant/houses.png';
const housePosition = this.falukantUser.house ? this.falukantUser.house.type.position : 0;
const x = housePosition % 3;
const y = Math.floor(housePosition / 3);
const pos = this.falukantUser.userHouse.houseType.position;
const index = pos - 1;
const columns = 3;
const spriteSize = 300;
const x = (index % columns) * spriteSize;
const y = Math.floor(index / columns) * spriteSize;
return {
backgroundImage: `url(${imageUrl})`,
backgroundPosition: `-${x * 341}px -${y * 341}px`,
backgroundSize: "341px 341px",
width: "114px",
height: "114px",
backgroundPosition: `-${x}px -${y}px`,
backgroundSize: `${columns * spriteSize}px auto`,
width: `300px`,
height: `300px`,
border: '1px solid #ccc',
borderRadius: '4px',
imageRendering: 'crisp-edges',
};
},
getAgeColor(age) {
@@ -321,12 +329,10 @@ export default {
border: 1px solid #ccc;
border-radius: 4px;
background-repeat: no-repeat;
background-size: cover;
image-rendering: crisp-edges;
}
h2 {
padding-top: 20px;
}
</style>

View File

@@ -0,0 +1,405 @@
<template>
<div class="politics-view">
<StatusBar />
<h2>{{ $t('falukant.politics.title') }}</h2>
<SimpleTabs v-model="activeTab" :tabs="tabs" @change="onTabChange" />
<!-- TabInhalt -->
<div class="tab-content">
<!-- Aktuelle Positionen -->
<div v-if="activeTab === 'current'" class="tab-pane">
<div v-if="loading.current" class="loading">{{ $t('loading') }}</div>
<div v-else class="table-scroll">
<table class="politics-table">
<thead>
<tr>
<th>{{ $t('falukant.politics.current.office') }}</th>
<th>{{ $t('falukant.politics.current.region') }}</th>
<th>{{ $t('falukant.politics.current.holder') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="pos in currentPositions" :key="pos.id">
<td>{{ $t(`falukant.politics.offices.${pos.officeType.name}`) }}</td>
<td>{{ pos.region.name }}</td>
<td>
<span v-if="pos.character">
{{ pos.character.definedFirstName.name }}
{{ pos.character.definedLastName.name }}
</span>
<span v-else></span>
</td>
</tr>
<tr v-if="!currentPositions.length">
<td colspan="3">{{ $t('falukant.politics.current.none') }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- OPEN Tab: hier zeigen wir 'openPolitics' -->
<div v-else-if="activeTab === 'openPolitics'" class="tab-pane">
<div v-if="loading.openPolitics" class="loading">{{ $t('loading') }}</div>
<div v-else class="table-scroll">
<table class="politics-table">
<thead>
<tr>
<th>{{ $t('falukant.politics.open.office') }}</th>
<th>{{ $t('falukant.politics.open.region') }}</th>
<th>{{ $t('falukant.politics.open.date') }}</th>
<th>{{ $t('falukant.politics.open.candidacy') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="e in openPolitics" :key="e.id">
<td>{{ $t(`falukant.politics.offices.${e.officeType.name}`) }}</td>
<td>{{ e.region.name }}</td>
<td>{{ formatDate(e.date) }}</td>
<!-- Checkbox ganz am Ende -->
<td>
<input type="checkbox" :id="`apply-${e.id}`" v-model="selectedApplications"
:value="e.id" />
</td>
</tr>
<tr v-if="!openPolitics.length">
<td colspan="4">{{ $t('falukant.politics.open.none') }}</td>
</tr>
</tbody>
</table>
</div>
<div class="apply-button">
<button :disabled="!selectedApplications.length" @click="submitApplications">
{{ $t('falukant.politics.open.apply') }}
</button>
</div>
</div>
<!-- Wahlen -->
<div v-else-if="activeTab === 'elections'" class="tab-pane">
<div v-if="loading.elections" class="loading">{{ $t('loading') }}</div>
<div v-else class="table-scroll">
<table class="politics-table">
<thead>
<tr>
<th>{{ $t('falukant.politics.elections.office') }}</th>
<th>{{ $t('falukant.politics.elections.region') }}</th>
<th>{{ $t('falukant.politics.elections.date') }}</th>
<th>{{ $t('falukant.politics.elections.posts') }}</th>
<th>{{ $t('falukant.politics.elections.candidates') }}</th>
<th>{{ $t('falukant.politics.elections.action') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="e in elections" :key="e.id">
<td>{{ $t(`falukant.politics.offices.${e.officeType.name}`) }}</td>
<td>{{ e.region.name }}</td>
<td>{{ formatDate(e.date) }}</td>
<td>{{ e.postsToFill }}</td>
<td v-if="!e.voted">
<Multiselect v-model="selectedCandidates[e.id]" :options="e.candidates" multiple
:max="e.postsToFill" :close-on-select="false" :clear-on-select="false"
track-by="id" label="name" :custom-label="candidateLabel" placeholder="">
<template #option="{ option }">
{{ $t(`falukant.titles.${option.gender}.${option.title}`) }}
{{ option.name }} ({{ option.age }})
</template>
<template #selected="{ option }">
{{ $t(`falukant.titles.${option.gender}.${option.title}`) }}
{{ option.name }}
</template>
</Multiselect>
</td>
<td v-else>
<ul class="voted-list">
<li v-for="cid in e.votedFor" :key="cid">
<span v-if="findCandidateById(e, cid)">
{{ formatCandidateTitle(findCandidateById(e, cid)) }}
{{ findCandidateById(e, cid).name }}
</span>
</li>
<li v-if="!e.votedFor || !e.votedFor.length"></li>
</ul>
</td>
<td>
<button v-if="!e.voted"
:disabled="!selectedCandidates[e.id] || !selectedCandidates[e.id].length"
@click="submitVote(e.id)">
{{ $t('falukant.politics.elections.vote') }}
</button>
</td>
</tr>
<tr v-if="!elections.length">
<td colspan="6">{{ $t('falukant.politics.elections.none') }}</td>
</tr>
</tbody>
</table>
</div>
<div class="all-vote-button" v-if="hasAnyUnvoted">
<button :disabled="!hasAnySelection" @click="submitAllVotes">
{{ $t('falukant.politics.elections.voteAll') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import StatusBar from '@/components/falukant/StatusBar.vue';
import SimpleTabs from '@/components/SimpleTabs.vue';
import Multiselect from 'vue-multiselect';
import apiClient from '@/utils/axios.js';
export default {
name: 'PoliticsView',
components: { StatusBar, SimpleTabs, Multiselect },
data() {
return {
activeTab: 'current',
tabs: [
{ value: 'current', label: 'falukant.politics.tabs.current' },
{ value: 'openPolitics', label: 'falukant.politics.tabs.upcoming' },
{ value: 'elections', label: 'falukant.politics.tabs.elections' }
],
currentPositions: [],
openPolitics: [],
elections: [],
selectedCandidates: {},
selectedApplications: [],
loading: {
current: false,
openPolitics: false,
elections: false
}
};
},
computed: {
hasAnySelection() {
return Object.values(this.selectedCandidates)
.some(arr => Array.isArray(arr) && arr.length > 0);
},
hasAnyUnvoted() {
return this.elections.some(e => !e.voted);
}
},
mounted() {
this.loadCurrentPositions();
},
methods: {
onTabChange(tab) {
if (tab === 'current' && !this.currentPositions.length) {
this.loadCurrentPositions();
}
if (tab === 'openPolitics' && !this.openPolitics.length) {
this.loadOpenPolitics();
}
if (tab === 'elections' && !this.elections.length) {
this.loadElections();
}
},
async loadCurrentPositions() {
this.loading.current = true;
try {
const { data } = await apiClient.get('/api/falukant/politics/overview');
this.currentPositions = data;
} catch (err) {
console.error('Error loading current positions', err);
} finally {
this.loading.current = false;
}
},
async loadOpenPolitics() {
this.loading.openPolitics = true;
try {
const { data } = await apiClient.get('/api/falukant/politics/open');
this.openPolitics = data;
this.selectedApplications = [];
} catch (err) {
console.error('Error loading open politics', err);
} finally {
this.loading.openPolitics = false;
}
},
async loadElections() {
this.loading.elections = true;
try {
const { data } = await apiClient.get('/api/falukant/politics/elections');
this.elections = data;
data.forEach(e => {
this.selectedCandidates[e.id] = [];
});
} catch (err) {
console.error('Error loading elections', err);
} finally {
this.loading.elections = false;
}
},
candidateLabel(option) {
const title = this.$t(`falukant.titles.${option.gender}.${option.title}`);
return `${title} ${option.name} (${option.age})`;
},
findCandidateById(election, candidateId) {
return election.candidates.find(c => c.id === candidateId) || {};
},
formatCandidateTitle(candidate) {
if (!candidate) return '';
return this.$t(`falukant.titles.${candidate.gender}.${candidate.title}`);
},
async submitVote(electionId) {
const singlePayload = [
{
electionId: electionId,
candidateIds: this.selectedCandidates[electionId].map(c => c.id)
}
];
try {
await apiClient.post(
'/api/falukant/politics/elections',
{ votes: singlePayload }
);
await this.loadElections();
} catch (err) {
console.error(`Error submitting vote for election ${electionId}`, err);
}
},
async submitAllVotes() {
const payload = Object.entries(this.selectedCandidates)
.filter(([eid, arr]) => Array.isArray(arr) && arr.length > 0)
.map(([eid, arr]) => ({
electionId: parseInt(eid, 10),
candidateIds: arr.map(c => c.id)
}));
try {
await apiClient.post(
'/api/falukant/politics/elections',
{ votes: payload }
);
await this.loadElections();
} catch (err) {
console.error('Error submitting all votes', err);
}
},
formatDate(ts) {
return new Date(ts).toLocaleDateString(this.$i18n.locale, {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
},
async submitApplications() {
try {
await apiClient.post(
'/api/falukant/politics/open',
{ electionIds: this.selectedApplications }
);
await this.loadOpenPolitics();
} catch (err) {
console.error('Error submitting applications', err);
}
}
}
};
</script>
<style scoped>
.politics-view {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
h2 {
margin: 0;
padding: 20px 0 0 0;
flex: 0 0 auto;
}
.simple-tabs {
flex: 0 0 auto;
}
.tab-content {
flex: 1 1 auto;
display: flex;
flex-direction: column;
overflow: hidden;
}
.tab-pane {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.table-scroll {
flex: 1;
overflow-y: auto;
border: 1px solid #ddd;
}
.politics-table {
border-collapse: collapse;
width: auto;
/* kein 100% */
}
.politics-table thead th {
position: sticky;
top: 0;
background: #FFF;
z-index: 1;
padding: 8px;
border: 1px solid #ddd;
text-align: left;
}
.politics-table tbody td {
padding: 8px;
border: 1px solid #ddd;
}
.loading {
text-align: center;
font-style: italic;
margin: 20px 0;
}
.voted-list {
list-style: none;
margin: 0;
padding: 0;
}
.all-vote-button {
padding: 10px 0;
text-align: right;
}
.all-vote-button button {
padding: 6px 12px;
cursor: pointer;
margin: 2em;
}
</style>

View File

@@ -2,89 +2,84 @@
<h2 class="link" @click="openForum()">{{ $t('socialnetwork.forum.title') }} {{ forumName }}</h2>
<h3 v-if="forumTopic">{{ forumTopic }}</h3>
<ul class="messages">
<li v-for="message in messages">
<li v-for="message in messages" :key="message.id">
<div v-html="message.text"></div>
<div class="footer">
<span class="link" @click="openProfile(message.lastMessageUser.hashedId)">{{
message.lastMessageUser.username }}</span>
<span class="link" @click="openProfile(message.lastMessageUser.hashedId)">
{{ message.lastMessageUser.username }}
</span>
<span>{{ new Date(message.createdAt).toLocaleString() }}</span>
</div>
</li>
</ul>
<editor v-model="newContent" :init="tinymceInitOptions" :api-key="apiKey"
tinymce-script-src="/tinymce/tinymce.min.js"></editor>
<div class="editor-container">
<EditorContent :editor="editor" class="editor" />
</div>
<button @click="saveNewMessage">{{ $t('socialnetwork.forum.createNewMesssage') }}</button>
</template>
<script>
import apiClient from '../../utils/axios';
import TinyMCEEditor from '@tinymce/tinymce-vue';
import { Editor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import apiClient from '../../utils/axios'
export default {
name: 'ForumTopicView',
components: {
editor: TinyMCEEditor,
EditorContent,
},
data() {
return {
forumTopicId: String,
forumTopicId: '',
forumTopic: null,
forumName: null,
forumId: 0,
newContent: '',
apiKey: import.meta.env.VITE_TINYMCE_API_KEY ?? '',
tinymceInitOptions: {
script_url: '/tinymce/tinymce.min.js',
height: 300,
menubar: true,
plugins: [
'lists', 'link',
'searchreplace', 'visualblocks', 'code',
'insertdatetime', 'table'
],
toolbar:
'undo redo cut copy paste | bold italic forecolor backcolor fontfamily fontsize| \
alignleft aligncenter alignright alignjustify | \
bullist numlist outdent indent | removeformat | link visualblocks code',
contextmenu: 'link image table',
menubar: 'edit format table',
promotion: false,
},
messages: [],
editor: null,
}
},
async mounted() {
mounted() {
this.forumTopicId = this.$route.params.id;
this.loadForumTopic();
this.editor = new Editor({
extensions: [StarterKit],
content: '',
});
},
beforeUnmount() {
if (this.editor) {
this.editor.destroy();
}
},
methods: {
async loadForumTopic() {
try {
console.log(this.forumTopicId);
const url = `/api/forum/topic/${this.forumTopicId}`;
console.log('url', url);
const response = await apiClient.get(url);
const response = await apiClient.get(`/api/forum/topic/${this.forumTopicId}`);
this.setContent(response.data);
} catch (error) {
console.error(error);
}
},
setContent(responseData) {
this.forumTopic = responseData.title;
this.forumName = responseData.forum.name;
this.forumId = responseData.forum.id;
this.messages = responseData.messages;
setContent(data) {
this.forumTopic = data.title;
this.forumName = data.forum.name;
this.forumId = data.forum.id;
this.messages = data.messages;
},
async openProfile(id) {
this.$root.$refs.userProfileDialog.userId = id;
this.$root.$refs.userProfileDialog.open();
},
async saveNewMessage() {
const content = this.editor ? this.editor.getHTML() : '';
if (!content.trim()) return;
try {
const url = `/api/forum/topic/${this.forumTopicId}/message`;
const response = await apiClient.post(url, {
content: this.newContent,
});
this.newContent = '';
const response = await apiClient.post(url, { content });
this.editor.commands.clearContent();
this.setContent(response.data);
} catch (error) {
console.error(error);
@@ -96,7 +91,6 @@ export default {
}
}
</script>
<style lang="scss" scoped>
.messages {
list-style-type: none;
@@ -117,11 +111,24 @@ export default {
display: flex;
}
.messages>li>.footer>span:first-child {
.messages > li > .footer > span:first-child {
flex: 1;
}
.messages > li > .footer > span:last-child {
text-align: right;
}
</style>
.editor-container {
margin-top: 1rem;
border: 1px solid #ccc;
padding: 10px;
min-height: 200px;
background-color: white;
}
.editor {
min-height: 150px;
outline: none;
}
</style>

View File

@@ -11,71 +11,27 @@
<input type="text" v-model="newTitle" />
</label>
</div>
<editor v-model="newContent" :init="tinymceInitOptions" :api-key="apiKey"
tinymce-script-src="/tinymce/tinymce.min.js"></editor>
<div class="editor-container">
<EditorContent :editor="editor" class="editor" />
</div>
<button @click="saveNewTopic">{{ $t('socialnetwork.forum.createNewTopic') }}</button>
</div>
<div v-else-if="titles.length > 0">
<div class="pagination">
<button @click="goToPage(1)" v-if="page != 1">&laquo; {{ $t('socialnetwork.forum.pagination.first')
}}</button>
<button @click="goToPage(page - 1)" v-if="page != 1">&lsaquo; {{
$t('socialnetwork.forum.pagination.previous') }}</button>
<span>{{ $t('socialnetwork.forum.pagination.page').replace("<<page>>", page).replace("<<of>>", totalPages)
}}</span>
<button @click="goToPage(page + 1)" v-if="page != totalPages">{{ $t('socialnetwork.forum.pagination.next')
}}
&rsaquo;</button>
<button @click="goToPage(totalPages)" v-if="page != totalPages">{{ $t('socialnetwork.forum.pagination.last')
}}
&raquo;</button>
</div>
<table>
<thead>
<tr>
<th>{{ $t('socialnetwork.forum.topic') }}</th>
<th>{{ $t('socialnetwork.forum.createdBy') }}</th>
<th>{{ $t('socialnetwork.forum.createdAt') }}</th>
<th>{{ $t('socialnetwork.forum.reactions') }}</th>
<th>{{ $t('socialnetwork.forum.lastReaction') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="title in titles">
<td><span class="link" @click="openTopic(title.id)">{{ title.title }}</span></td>
<td><span class="link" @click="openProfile(title.createdByHash)">{{ title.createdBy }}</span></td>
<td>{{ new Date(title.createdAt).toLocaleString() }}</td>
<td>{{ title.numberOfItems }}</td>
<td>{{ new Date(title.lastMessageDate).toLocaleString() }}</td>
</tr>
</tbody>
</table>
<div class="pagination">
<button @click="goToPage(1)" v-if="page != 1">&laquo; {{ $t('socialnetwork.forum.pagination.first')
}}</button>
<button @click="goToPage(page - 1)" v-if="page != 1">&lsaquo; {{
$t('socialnetwork.forum.pagination.previous') }}</button>
<span>{{ $t('socialnetwork.forum.pagination.page').replace("<<page>>", page).replace("<<of>>", totalPages)
}}</span>
<button @click="goToPage(page + 1)" v-if="page != totalPages">{{ $t('socialnetwork.forum.pagination.next')
}}
&rsaquo;</button>
<button @click="goToPage(totalPages)" v-if="page != totalPages">{{ $t('socialnetwork.forum.pagination.last')
}}
&raquo;</button>
</div>
<!-- PAGINATION + TABLE bleibt unverändert -->
<!-- ... -->
</div>
<div v-else>{{ $t('socialnetwork.forum.noTitles') }}</div>
</template>
<script>
import apiClient from '../../utils/axios';
import TinyMCEEditor from '@tinymce/tinymce-vue';
import { Editor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import apiClient from '../../utils/axios'
export default {
name: 'ForumView',
components: {
editor: TinyMCEEditor,
EditorContent,
},
computed: {
totalPages() {
@@ -91,30 +47,20 @@ export default {
titles: [],
inCreation: false,
newTitle: '',
newContent: '',
apiKey: import.meta.env.VITE_TINYMCE_API_KEY ?? '',
tinymceInitOptions: {
script_url: '/tinymce/tinymce.min.js',
height: 300,
menubar: true,
plugins: [
'lists', 'link',
'searchreplace', 'visualblocks', 'code',
'insertdatetime', 'table'
],
toolbar:
'undo redo cut copy paste | bold italic forecolor backcolor fontfamily fontsize| \
alignleft aligncenter alignright alignjustify | \
bullist numlist outdent indent | removeformat | link visualblocks code',
contextmenu: 'link image table',
menubar: 'edit format table',
promotion: false,
},
editor: null,
}
},
async mounted() {
this.forumId = this.$route.params.id;
await this.loadForum();
this.editor = new Editor({
extensions: [StarterKit],
content: '',
});
},
beforeUnmount() {
if (this.editor) this.editor.destroy();
},
methods: {
async loadForum() {
@@ -123,12 +69,16 @@ export default {
},
createNewTopic() {
this.inCreation = !this.inCreation;
if (this.inCreation && this.editor) {
this.editor.commands.setContent('');
}
},
async saveNewTopic() {
const content = this.editor ? this.editor.getHTML() : '';
const response = await apiClient.post('/api/forum/topic', {
forumId: this.forumId,
title: this.newTitle,
content: this.newEntryContent
content,
});
this.setData(response.data);
this.inCreation = false;
@@ -171,6 +121,19 @@ export default {
flex: 1;
}
.editor-container {
margin: 1em 0;
border: 1px solid #ccc;
padding: 10px;
min-height: 200px;
background-color: white;
}
.editor {
min-height: 150px;
outline: none;
}
.pagination {
display: flex;
justify-content: center;
@@ -185,4 +148,4 @@ export default {
.pagination span {
padding: 0.5em;
}
</style>
</style>