Add Falukant region and transport management features

- Implemented new endpoints in AdminController for managing Falukant regions, including fetching, updating, and deleting region distances.
- Enhanced the FalukantService with methods for retrieving region distances and handling upsert operations.
- Updated the router to expose new routes for region management and transport creation.
- Introduced a transport management interface in the frontend, allowing users to create and manage transports between branches.
- Added localization for new transport-related terms and improved the vehicle management interface to include transport options.
- Enhanced the database initialization logic to support new region and transport models.
This commit is contained in:
Torsten Schulz (local)
2025-11-26 16:44:27 +01:00
parent 29dd7ec80c
commit 06ea259dc9
27 changed files with 2100 additions and 57 deletions

View File

@@ -1,6 +1,6 @@
<template>
<div class="sale-section">
<!-- Beispielhafte Inventar-Tabelle -->
<!-- Inventar-Tabelle -->
<div v-if="inventory.length > 0" class="inventory-table">
<table>
<thead>
@@ -30,6 +30,121 @@
<div v-else>
<p>{{ $t('falukant.branch.sale.noInventory') }}</p>
</div>
<!-- Transport anlegen (nur wenn Inventar vorhanden) -->
<div class="transport-form" v-if="inventory.length > 0">
<h4>{{ $t('falukant.branch.sale.transportTitle') }}</h4>
<div class="transport-row">
<label>
{{ $t('falukant.branch.sale.transportSource') }}
<select v-model.number="transportForm.sourceKey" @change="() => { recalcMaxQuantity(); loadRouteInfo(); }">
<option :value="null" disabled>{{ $t('falukant.branch.sale.transportSourcePlaceholder') }}</option>
<option v-for="opt in inventoryOptions()" :key="opt.key" :value="opt.key">
{{ opt.label }}
</option>
</select>
</label>
<label>
{{ $t('falukant.branch.sale.transportVehicle') }}
<select v-model.number="transportForm.vehicleTypeId" @change="() => { recalcMaxQuantity(); loadRouteInfo(); }">
<option :value="null" disabled>{{ $t('falukant.branch.sale.transportVehiclePlaceholder') }}</option>
<option v-for="vt in vehicleTypeOptions()" :key="vt.id" :value="vt.id">
{{ $t(`falukant.branch.vehicles.${vt.tr}`) }} ({{ vt.count }} × {{ vt.capacity }})
</option>
</select>
</label>
<label>
{{ $t('falukant.branch.sale.transportTarget') }}
<select v-model.number="transportForm.targetBranchId" @change="loadRouteInfo">
<option :value="null" disabled>{{ $t('falukant.branch.sale.transportTargetPlaceholder') }}</option>
<option v-for="tb in targetBranchOptions()" :key="tb.id" :value="tb.id">
{{ tb.label }}
</option>
</select>
</label>
<label>
{{ $t('falukant.branch.sale.transportQuantity') }}
<input
type="number"
v-model.number="transportForm.quantity"
:min="1"
:max="transportForm.maxQuantity || 0"
@input="recalcCost"
/>
<span v-if="transportForm.maxQuantity">
({{ $t('falukant.branch.sale.transportMax', { max: transportForm.maxQuantity }) }})
</span>
</label>
<div v-if="transportForm.costLabel">
{{ $t('falukant.branch.sale.transportCost', { cost: transportForm.costLabel }) }}
</div>
<button
@click="createTransport"
:disabled="
transportForm.sourceKey === null ||
transportForm.sourceKey === undefined ||
!transportForm.vehicleTypeId ||
!transportForm.targetBranchId ||
!transportForm.maxQuantity ||
!transportForm.quantity
"
>
{{ $t('falukant.branch.sale.transportCreate') }}
</button>
</div>
<div class="transport-route" v-if="transportForm.durationLabel">
<div>
{{ $t('falukant.branch.sale.transportDuration', { duration: transportForm.durationLabel }) }}
</div>
<div>
{{ $t('falukant.branch.sale.transportArrival', { datetime: transportForm.etaLabel }) }}
</div>
<div v-if="transportForm.routeNames && transportForm.routeNames.length">
{{ $t('falukant.branch.sale.transportRoute') }}:
{{ transportForm.routeNames.join(' ') }}
</div>
</div>
</div>
<!-- Laufende Transporte (immer im Inventar-Tab sichtbar, auch ohne Inventar) -->
<div class="running-transports" v-if="runningTransports.length">
<h5>{{ $t('falukant.branch.sale.runningTransportsTitle') }}</h5>
<table>
<thead>
<tr>
<th>{{ $t('falukant.branch.sale.runningDirection') }}</th>
<th>{{ $t('falukant.branch.sale.runningProduct') }}</th>
<th>{{ $t('falukant.branch.sale.runningQuantity') }}</th>
<th>{{ $t('falukant.branch.sale.runningSource') }}</th>
<th>{{ $t('falukant.branch.sale.runningTarget') }}</th>
<th>{{ $t('falukant.branch.sale.runningEta') }}</th>
<th>{{ $t('falukant.branch.sale.runningRemaining') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="t in runningTransports" :key="t.id">
<td>
{{ t.direction === 'outgoing'
? $t('falukant.branch.sale.runningDirectionOut')
: $t('falukant.branch.sale.runningDirectionIn') }}
</td>
<td>{{ $t(`falukant.product.${t.product.labelTr}`) }}</td>
<td>{{ t.size }}</td>
<td>{{ t.sourceRegion?.name }}</td>
<td>{{ t.targetRegion?.name }}</td>
<td>{{ formatEta(t) }}</td>
<td>{{ formatRemaining(t) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
@@ -37,14 +152,46 @@
import apiClient from '@/utils/axios.js';
export default {
name: "SaleSection",
props: { branchId: { type: Number, required: true } },
props: {
branchId: { type: Number, required: true },
vehicles: { type: Array, default: () => [] },
branches: { type: Array, default: () => [] },
},
data() {
return {
inventory: [],
transportForm: {
sourceKey: null,
vehicleTypeId: null,
targetBranchId: null,
quantity: 0,
maxQuantity: 0,
distance: null,
durationHours: null,
eta: null,
durationLabel: '',
etaLabel: '',
routeNames: [],
cost: null,
costLabel: '',
},
runningTransports: [],
nowTs: Date.now(),
_transportTimer: null,
};
},
async mounted() {
await this.loadInventory();
await this.loadTransports();
this._transportTimer = setInterval(() => {
this.nowTs = Date.now();
}, 1000);
},
beforeUnmount() {
if (this._transportTimer) {
clearInterval(this._transportTimer);
this._transportTimer = null;
}
},
methods: {
async loadInventory() {
@@ -76,6 +223,205 @@
alert(this.$t('falukant.branch.sale.sellAllError'));
});
},
inventoryOptions() {
return this.inventory.map((item, index) => ({
key: index,
label: `${this.$t(`falukant.product.${item.product.labelTr}`)} (Q${item.quality}, ${item.totalQuantity})`,
productId: item.product.id,
totalQuantity: item.totalQuantity,
}));
},
vehicleTypeOptions() {
const groups = {};
for (const v of this.vehicles) {
if (v.status !== 'available' || !v.type || !v.type.id) continue;
const id = v.type.id;
if (!groups[id]) {
groups[id] = {
id,
tr: v.type.tr,
capacity: v.type.capacity,
speed: v.type.speed,
count: 0,
};
}
groups[id].count += 1;
}
return Object.values(groups);
},
targetBranchOptions() {
return (this.branches || [])
.filter(b => ['store', 'fullstack'].includes(b.branchTypeLabelTr))
// aktuelle Niederlassung darf nicht als Ziel angeboten werden
.filter(b => b.id !== this.branchId)
.map(b => ({
id: b.id,
label: `${b.cityName} ${b.type}`,
}));
},
recalcMaxQuantity() {
const source = this.inventoryOptions().find(o => o.key === this.transportForm.sourceKey);
const vType = this.vehicleTypeOptions().find(v => v.id === this.transportForm.vehicleTypeId);
if (!source || !vType) {
this.transportForm.maxQuantity = 0;
this.transportForm.quantity = 0;
this.recalcCost();
return;
}
const maxByInventory = source.totalQuantity;
const maxByVehicles = vType.capacity * vType.count;
const max = Math.min(maxByInventory, maxByVehicles);
this.transportForm.maxQuantity = max;
if (!this.transportForm.quantity || this.transportForm.quantity > max) {
this.transportForm.quantity = max;
}
this.recalcCost();
},
recalcCost() {
const idx = this.transportForm.sourceKey;
if (idx === null || idx === undefined) {
this.transportForm.cost = null;
this.transportForm.costLabel = '';
return;
}
const item = this.inventory[idx];
const qty = this.transportForm.quantity || 0;
if (!item || !item.product || item.product.sellCost == null || qty <= 0) {
this.transportForm.cost = null;
this.transportForm.costLabel = '';
return;
}
const unitValue = item.product.sellCost || 0;
const totalValue = unitValue * qty;
const cost = Math.max(0.1, totalValue * 0.01);
this.transportForm.cost = cost;
this.transportForm.costLabel = this.formatMoney(cost);
},
formatMoney(amount) {
if (amount == null) return '';
try {
return amount.toLocaleString(undefined, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
} catch (e) {
return String(amount);
}
},
async loadRouteInfo() {
this.transportForm.distance = null;
this.transportForm.durationHours = null;
this.transportForm.eta = null;
this.transportForm.durationLabel = '';
this.transportForm.etaLabel = '';
this.transportForm.routeNames = [];
const sourceOpt = this.inventoryOptions().find(o => o.key === this.transportForm.sourceKey);
const vType = this.vehicleTypeOptions().find(v => v.id === this.transportForm.vehicleTypeId);
const targetBranch = (this.branches || []).find(b => b.id === this.transportForm.targetBranchId);
if (!sourceOpt || !vType || !targetBranch) {
return;
}
const sourceBranch = (this.branches || []).find(b => b.id === this.branchId);
if (!sourceBranch) {
return;
}
try {
const { data } = await apiClient.get('/api/falukant/transports/route', {
params: {
sourceRegionId: sourceBranch.regionId,
targetRegionId: targetBranch.regionId,
vehicleTypeId: vType.id,
},
});
if (data && data.totalDistance != null) {
const distance = data.totalDistance;
const speed = vType.speed || 1;
const hours = distance / speed;
this.transportForm.distance = distance;
this.transportForm.durationHours = hours;
const now = new Date();
const etaMs = now.getTime() + hours * 60 * 60 * 1000;
const etaDate = new Date(etaMs);
const fullHours = Math.floor(hours);
const minutes = Math.round((hours - fullHours) * 60);
const parts = [];
if (fullHours > 0) parts.push(`${fullHours} h`);
if (minutes > 0) parts.push(`${minutes} min`);
this.transportForm.durationLabel = parts.length ? parts.join(' ') : '0 min';
this.transportForm.etaLabel = etaDate.toLocaleString();
this.transportForm.routeNames = (data.regions || []).map(r => r.name);
}
} catch (error) {
console.error('Error loading transport route:', error);
this.transportForm.distance = null;
this.transportForm.durationHours = null;
this.transportForm.eta = null;
this.transportForm.durationLabel = '';
this.transportForm.etaLabel = '';
this.transportForm.routeNames = [];
}
},
async createTransport() {
const source = this.inventoryOptions().find(o => o.key === this.transportForm.sourceKey);
if (!source) return;
try {
await apiClient.post('/api/falukant/transports', {
branchId: this.branchId,
vehicleTypeId: this.transportForm.vehicleTypeId,
productId: source.productId,
quantity: this.transportForm.quantity,
targetBranchId: this.transportForm.targetBranchId,
});
await this.loadInventory();
await this.loadTransports();
alert(this.$t('falukant.branch.sale.transportStarted'));
this.$emit('transportCreated');
} catch (error) {
console.error('Error creating transport:', error);
alert(this.$t('falukant.branch.sale.transportError'));
}
},
async loadTransports() {
try {
const { data } = await apiClient.get(`/api/falukant/transports/branch/${this.branchId}`);
this.runningTransports = Array.isArray(data) ? data : [];
} catch (error) {
console.error('Error loading transports:', error);
this.runningTransports = [];
}
},
formatEta(transport) {
if (!transport || !transport.eta) return '';
const etaDate = new Date(transport.eta);
if (Number.isNaN(etaDate.getTime())) return '';
return etaDate.toLocaleString();
},
formatRemaining(transport) {
if (!transport || !transport.eta) return '';
const etaMs = new Date(transport.eta).getTime();
if (Number.isNaN(etaMs)) return '';
let diff = Math.floor((etaMs - this.nowTs) / 1000);
if (diff <= 0) {
return this.$t('falukant.branch.production.noProductions') ? '0s' : '0s';
}
const hours = Math.floor(diff / 3600);
diff %= 3600;
const minutes = Math.floor(diff / 60);
const seconds = diff % 60;
const parts = [];
if (hours > 0) parts.push(`${hours}h`);
if (minutes > 0 || hours > 0) parts.push(`${minutes}m`);
parts.push(`${seconds}s`);
return parts.join(' ');
},
},
};
</script>
@@ -96,5 +442,11 @@
padding: 2px 3px;
border: 1px solid #ddd;
}
/* Größerer Abstand zwischen den Spalten der Transport-Tabelle */
.running-transports table {
border-collapse: separate;
border-spacing: 16px 0; /* horizontaler Abstand zwischen Spalten */
}
</style>

View File

@@ -24,6 +24,18 @@
</select>
</label>
<label class="form-label">
{{ $t('falukant.branch.transport.mode') }}
<select v-model="mode" class="form-control">
<option value="buy">
{{ $t('falukant.branch.transport.modeBuy') }}
</option>
<option value="build">
{{ $t('falukant.branch.transport.modeBuild') }}
</option>
</select>
</label>
<label class="form-label">
{{ $t('falukant.branch.transport.quantity') }}
<input
@@ -39,6 +51,11 @@
<strong>{{ formatCost(totalCost) }}</strong>
</p>
<p v-if="selectedType" class="buildtime">
{{ $t('falukant.branch.transport.buildTime') }}:
<span>{{ formattedBuildTime }}</span>
</p>
<p v-if="totalCost > money" class="warning">
{{ $t('falukant.branch.transport.notEnoughMoney') }}
</p>
@@ -64,6 +81,7 @@ export default {
vehicleTypes: [],
selectedTypeId: null,
quantity: 1,
mode: 'buy',
money: 0,
loaded: false,
};
@@ -73,7 +91,10 @@ export default {
return [
{ text: this.$t('Cancel'), action: this.close },
{
text: this.$t('falukant.branch.transport.buy'),
text:
this.mode === 'build'
? this.$t('falukant.branch.transport.buildAction')
: this.$t('falukant.branch.transport.buyAction'),
action: this.onConfirm,
disabled: !this.canBuy,
},
@@ -85,7 +106,11 @@ export default {
totalCost() {
if (!this.selectedType) return 0;
const q = Math.max(1, this.quantity || 0);
return this.selectedType.cost * q;
const unit =
this.mode === 'build'
? Math.round(this.selectedType.cost * 0.75)
: this.selectedType.cost;
return unit * q;
},
canBuy() {
return (
@@ -99,11 +124,23 @@ export default {
formattedMoney() {
return this.formatCost(this.money);
},
formattedBuildTime() {
if (!this.selectedType || !this.selectedType.buildTimeMinutes) return '-';
const total = this.selectedType.buildTimeMinutes;
const h = Math.floor(total / 60);
const m = total % 60;
if (h > 0 && m > 0) {
return `${h} h ${m} min`;
}
if (h > 0) return `${h} h`;
return `${m} min`;
},
},
methods: {
async open() {
this.loaded = false;
this.quantity = 1;
this.mode = 'buy';
await Promise.all([this.loadVehicleTypes(), this.loadMoney()]);
if (this.vehicleTypes.length && !this.selectedTypeId) {
this.selectedTypeId = this.vehicleTypes[0].id;
@@ -139,6 +176,7 @@ export default {
vehicleTypeId: this.selectedTypeId,
quantity: this.quantity,
regionId: this.regionId,
mode: this.mode,
});
this.$emit('bought');
this.close();

View File

@@ -11,15 +11,18 @@
<div class="create-branch-form">
<div class="map-wrapper">
<!-- linke Spalte: Karte + Regionen + Dev-Draw -->
<div class="map-container">
<div
class="map-container"
@mousedown="onMouseDown"
@mousemove="onMouseMove"
@mouseup="onMouseUp"
@mouseleave="onMouseUp"
>
<img
ref="mapImage"
src="/images/falukant/map.png"
class="map"
@mousedown="onMouseDown"
@mousemove="onMouseMove"
@mouseup="onMouseUp"
@mouseleave="onMouseUp"
@load="onMapLoaded"
@dragstart.prevent
/>
<div
@@ -27,12 +30,7 @@
: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'
}"
:style="cityRegionStyle(city.map)"
@click="city.branches.length === 0 && onCityClick(city)"
:title="city.name"
></div>
@@ -101,7 +99,9 @@
startX: null,
startY: null,
currentX: 0,
currentY: 0,
currentY: 0,
mapWidth: 0,
mapHeight: 0,
};
},
@@ -130,6 +130,11 @@
},
methods: {
onMapLoaded() {
const bounds = this.$refs.mapImage.getBoundingClientRect();
this.mapWidth = bounds.width;
this.mapHeight = bounds.height;
},
open() {
this.$refs.dialog.open();
},
@@ -149,6 +154,12 @@
this.$emit('create-branch');
this.close();
} catch (e) {
if (e?.response?.status === 412 && e?.response?.data?.error === 'insufficientFunds') {
alert(this.$t('falukant.branch.actions.insufficientFunds'));
} else {
console.error('Error creating branch', e);
alert(this.$t('falukant.error.generic') || 'Fehler beim Erstellen der Niederlassung.');
}
}
},
@@ -208,6 +219,21 @@
height: Math.round(height),
};
},
cityRegionStyle(map) {
if (!map || !this.mapWidth || !this.mapHeight) return {};
const toPxX = (v) => (v >= 0 && v <= 1 ? v * this.mapWidth : v);
const toPxY = (v) => (v >= 0 && v <= 1 ? v * this.mapHeight : v);
const x = toPxX(map.x);
const y = toPxY(map.y);
const w = toPxX(map.w);
const h = toPxY(map.h);
return {
top: `${y}px`,
left: `${x}px`,
width: `${w}px`,
height: `${h}px`,
};
},
async loadCities() {
const { data } = await apiClient.get('/api/falukant/cities');

View File

@@ -85,6 +85,34 @@
"errorAddingStock": "Fehler beim Hinzufügen des Lagers.",
"stockAdded": "Lager erfolgreich hinzugefügt.",
"invalidStockData": "Bitte gültige Lagertyp- und Mengenangaben eingeben."
},
"map": {
"title": "Falukant Karten-Editor (Regionen)",
"description": "Zeichne Rechtecke auf der Falukant-Karte und weise sie Städten zu.",
"tabs": {
"regions": "Positionen",
"distances": "Entfernungen"
},
"regionList": "Städte",
"noCoords": "Keine Koordinaten gesetzt",
"currentRect": "Aktuelles Rechteck",
"hintDraw": "Wähle eine Stadt und ziehe mit der Maus ein Rechteck auf der Karte, um die Position festzulegen.",
"saveAll": "Alle geänderten Städte speichern",
"connectionsTitle": "Verbindungen (region_distance)",
"source": "Von",
"target": "Nach",
"selectSource": "Quellstadt wählen",
"selectTarget": "Zielstadt wählen",
"mode": "Transportart",
"modeLand": "Land",
"modeWater": "Wasser",
"modeAir": "Luft",
"distance": "Entfernung",
"saveConnection": "Verbindung speichern",
"pickOnMap": "Auf Karte wählen",
"errorSaveConnection": "Die Verbindung konnte nicht gespeichert werden.",
"errorDeleteConnection": "Die Verbindung konnte nicht gelöscht werden.",
"confirmDeleteConnection": "Verbindung wirklich löschen?"
}
},
"chatrooms": {

View File

@@ -147,7 +147,33 @@
"loadError": "Fehler beim Laden des Inventars.",
"sell": "Verkauf",
"sellButton": "Verkaufen",
"sellAllButton": "Alles verkaufen"
"sellAllButton": "Alles verkaufen",
"transportTitle": "Transport anlegen",
"transportSource": "Artikel",
"transportSourcePlaceholder": "Artikel wählen",
"transportVehicle": "Transportmittel",
"transportVehiclePlaceholder": "Transportmittel wählen",
"transportTarget": "Zielstadt",
"transportTargetPlaceholder": "Ziel wählen",
"transportQuantity": "Menge",
"transportMax": "Maximal: {max}",
"transportCreate": "Transport starten",
"transportError": "Transport konnte nicht angelegt werden.",
"transportDuration": "Transportdauer: {duration}",
"transportArrival": "Ankunftszeit: {datetime}",
"transportRoute": "Route",
"transportCost": "Transportkosten: {cost}",
"transportStarted": "Der Transport wurde gestartet.",
"runningTransportsTitle": "Laufende Transporte",
"runningDirection": "Richtung",
"runningProduct": "Artikel",
"runningQuantity": "Menge",
"runningSource": "Quelle",
"runningTarget": "Ziel",
"runningEta": "Ankunft",
"runningRemaining": "Restzeit",
"runningDirectionOut": "Ausgehend",
"runningDirectionIn": "Eingehend"
},
"production": {
"title": "Produktion",
@@ -215,13 +241,34 @@
},
"transport": {
"title": "Transportmittel",
"placeholder": "Hier kannst du Transportmittel für deine Region kaufen.",
"placeholder": "Hier kannst du Transportmittel für deine Region kaufen oder bauen.",
"vehicleType": "Transportmittel",
"mode": "Art",
"modeBuy": "Kaufen (sofort verfügbar)",
"modeBuild": "Bauen (75 % Kosten, mit Bauzeit)",
"quantity": "Anzahl",
"totalCost": "Gesamtkosten",
"notEnoughMoney": "Du hast nicht genug Geld für diesen Kauf.",
"buy": "Transportmittel kaufen",
"balance": "Kontostand"
"buildTime": "Bauzeit",
"notEnoughMoney": "Du hast nicht genug Geld für diese Aktion.",
"buyAction": "Transportmittel kaufen",
"buildAction": "Transportmittel bauen",
"buy": "Transportmittel kaufen/bauen",
"balance": "Kontostand",
"noVehicles": "Du besitzt in dieser Region noch keine Transportmittel.",
"table": {
"type": "Typ",
"capacity": "Kapazität",
"condition": "Zustand",
"mode": "Art",
"speed": "Geschwindigkeit",
"availableFrom": "Verfügbar ab",
"status": "Status"
},
"status": {
"inUse": "In Benutzung (mit Transport verknüpft)",
"building": "Im Bau",
"free": "Verfügbar"
}
},
"stocktype": {
"wood": "Holzlager",
@@ -358,7 +405,11 @@
"Buy storage (type: field)": "Lagerplatz gekauft (Typ: Feld)",
"Buy storage (type: iron)": "Lagerplatz gekauft (Typ: Eisen)",
"Buy storage (type: stone)": "Lagerplatz gekauft (Typ: Stein)",
"Buy storage (type: wood)": "Lagerplatz gekauft (Typ: Holz)"
"Buy storage (type: wood)": "Lagerplatz gekauft (Typ: Holz)",
"create_branch": "Niederlassung gegründet",
"buy_vehicles": "Transportmittel gekauft",
"build_vehicles": "Transportmittel gebaut",
"transport": "Transport"
}
},
"newdirector": {

View File

@@ -57,7 +57,8 @@
"m-falukant": {
"logentries": "Log-Einträge",
"edituser": "Benutzer bearbeiten",
"database": "Datenbank"
"database": "Datenbank",
"mapEditor": "Karteneditor"
},
"minigames": "Minispiele",
"m-minigames": {

View File

@@ -57,7 +57,8 @@
"m-falukant": {
"logentries": "Log entries",
"edituser": "Edit user",
"database": "Database"
"database": "Database",
"mapEditor": "Map editor"
},
"minigames": "Mini games",
"m-minigames": {

View File

@@ -4,6 +4,7 @@ import RoomsView from '../views/admin/RoomsView.vue';
import UserRightsView from '../views/admin/UserRightsView.vue';
import ForumAdminView from '../dialogues/admin/ForumAdminView.vue';
import AdminFalukantEditUserView from '../views/admin/falukant/EditUserView.vue';
import AdminFalukantMapRegionsView from '../views/admin/falukant/MapRegionsView.vue';
import AdminMinigamesView from '../views/admin/MinigamesView.vue';
import AdminTaxiToolsView from '../views/admin/TaxiToolsView.vue';
import AdminUsersView from '../views/admin/UsersView.vue';
@@ -59,6 +60,12 @@ const adminRoutes = [
component: AdminFalukantEditUserView,
meta: { requiresAuth: true }
},
{
path: '/admin/falukant/map',
name: 'AdminFalukantMapRegionsView',
component: AdminFalukantMapRegionsView,
meta: { requiresAuth: true }
},
{
path: '/admin/minigames/match3',
name: 'AdminMinigames',

View File

@@ -0,0 +1,631 @@
<template>
<div class="contenthidden">
<div class="contentscroll falukant-map-admin">
<div class="admin-header">
<h1>{{ $t('admin.falukant.map.title') }}</h1>
<p>{{ $t('admin.falukant.map.description') }}</p>
</div>
<div class="map-layout">
<div
class="map-container"
@mousedown="onMouseDown"
@mousemove="onMouseMove"
@mouseup="onMouseUp"
@mouseleave="onMouseUp"
>
<img
ref="mapImage"
src="/images/falukant/map.png"
class="map"
@load="onMapLoaded"
@dragstart.prevent
/>
<!-- vorhandene Regionen-Rechtecke -->
<template v-for="region in regions" :key="region.id">
<div
v-if="region.map"
class="region-rect"
:class="regionClasses(region)"
:style="rectStyle(region.map)"
@click.stop="selectRegion(region)"
:title="region.name"
></div>
</template>
<!-- aktuell gezeichneter Bereich -->
<div
v-if="drawingRect"
class="region-rect drawing"
:style="rectStyle(drawingRect)"
></div>
</div>
<div class="sidebar">
<h2>{{ $t('admin.falukant.map.regionList') }}</h2>
<ul class="region-list">
<li
v-for="region in regions"
:key="region.id"
:class="regionListClasses(region)"
@click="selectRegion(region)"
>
{{ region.name }}
<span v-if="region.map" class="coords">
({{ region.map.x }},{{ region.map.y }} {{ region.map.w }}×{{ region.map.h }})
</span>
<span v-else class="coords missing">
{{ $t('admin.falukant.map.noCoords') }}
</span>
</li>
</ul>
<SimpleTabs v-model="activeTab" :tabs="tabs" />
<div v-if="activeTab === 'regions'">
<div v-if="selectedRegion" class="details">
<h3>{{ selectedRegion.name }}</h3>
<p v-if="selectedRegion.map">
{{ $t('admin.falukant.map.currentRect') }}:
{{ selectedRegion.map.x }},{{ selectedRegion.map.y }}
{{ selectedRegion.map.w }}×{{ selectedRegion.map.h }}
</p>
<p v-else>
{{ $t('admin.falukant.map.noCoords') }}
</p>
<p class="hint">
{{ $t('admin.falukant.map.hintDraw') }}
</p>
<button
class="btn btn-primary"
:disabled="!selectedRegionDirty"
@click="saveSelectedRegion"
>
{{ $t('common.save') }}
</button>
<button
class="btn btn-secondary"
:disabled="!dirtyRegionIds.length"
@click="saveAllRegions"
>
{{ $t('admin.falukant.map.saveAll') }}
</button>
</div>
</div>
<div v-else-if="activeTab === 'distances'" class="connections">
<h3>{{ $t('admin.falukant.map.connectionsTitle') }}</h3>
<div class="connection-form">
<table class="connection-form-table">
<tbody>
<tr>
<td class="label-cell">
{{ $t('admin.falukant.map.source') }}
</td>
<td class="field-cell">
<div class="field-row">
<select v-model.number="newConnection.sourceRegionId">
<option :value="null" disabled>{{ $t('admin.falukant.map.selectSource') }}</option>
<option v-for="r in regions" :key="`src-${r.id}`" :value="r.id">
{{ r.name }}
</option>
</select>
<button
type="button"
class="btn mini icon"
@click="pickMode = 'source'"
:title="$t('admin.falukant.map.pickOnMap')"
>
</button>
</div>
</td>
</tr>
<tr>
<td class="label-cell">
{{ $t('admin.falukant.map.target') }}
</td>
<td class="field-cell">
<div class="field-row">
<select v-model.number="newConnection.targetRegionId">
<option :value="null" disabled>{{ $t('admin.falukant.map.selectTarget') }}</option>
<option v-for="r in regions" :key="`tgt-${r.id}`" :value="r.id">
{{ r.name }}
</option>
</select>
<button
type="button"
class="btn mini icon"
@click="pickMode = 'target'"
:title="$t('admin.falukant.map.pickOnMap')"
>
</button>
</div>
</td>
</tr>
<tr>
<td class="label-cell">
{{ $t('admin.falukant.map.mode') }}
</td>
<td class="field-cell">
<select v-model="newConnection.transportMode">
<option value="land">{{ $t('admin.falukant.map.modeLand') }}</option>
<option value="water">{{ $t('admin.falukant.map.modeWater') }}</option>
<option value="air">{{ $t('admin.falukant.map.modeAir') }}</option>
</select>
</td>
</tr>
<tr>
<td class="label-cell">
{{ $t('admin.falukant.map.distance') }}
</td>
<td class="field-cell">
<input
type="number"
min="0.1"
step="0.1"
v-model.number="newConnection.distance"
/>
</td>
</tr>
<tr>
<td colspan="2" class="connection-actions-cell">
<button
class="btn btn-primary"
:disabled="!newConnection.sourceRegionId || !newConnection.targetRegionId || !newConnection.distance"
@click="saveConnection"
>
{{ $t('admin.falukant.map.saveConnection') }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<table v-if="connections && connections.length" class="connections-table">
<thead>
<tr>
<th>{{ $t('admin.falukant.map.source') }}</th>
<th>{{ $t('admin.falukant.map.target') }}</th>
<th>{{ $t('admin.falukant.map.mode') }}</th>
<th>{{ $t('admin.falukant.map.distance') }}</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="c in connections" :key="c.id">
<td>{{ regionName(c.sourceRegionId) }}</td>
<td>{{ regionName(c.targetRegionId) }}</td>
<td>{{ c.transportMode }}</td>
<td>{{ c.distance }}</td>
<td>
<button class="btn btn-secondary" @click="deleteConnection(c.id)">
{{ $t('common.delete') }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import apiClient from '@/utils/axios.js';
import SimpleTabs from '@/components/SimpleTabs.vue';
export default {
name: 'AdminFalukantMapRegionsView',
components: { SimpleTabs },
data() {
return {
regions: [],
selectedRegion: null,
selectedRegionDirty: false,
dirtyRegionIds: [],
drawingRect: null,
startX: null,
startY: null,
currentX: 0,
currentY: 0,
connections: [],
newConnection: {
sourceRegionId: null,
targetRegionId: null,
transportMode: 'land',
distance: 1,
},
activeTab: 'regions',
tabs: [
{ value: 'regions', label: 'admin.falukant.map.tabs.regions' },
{ value: 'distances', label: 'admin.falukant.map.tabs.distances' },
],
pickMode: null,
};
},
async mounted() {
await this.loadRegions();
await this.loadConnections();
},
methods: {
onMapLoaded() {
const bounds = this.$refs.mapImage.getBoundingClientRect();
this.mapWidth = bounds.width;
this.mapHeight = bounds.height;
},
async loadRegions() {
try {
const { data } = await apiClient.get('/api/admin/falukant/regions');
// Sicherstellen, dass map-Objekte existieren oder null sind
this.regions = (data || []).map(r => ({
...r,
map: r.map || null,
}));
} catch (error) {
console.error('Error loading Falukant regions:', error);
}
},
async loadConnections() {
try {
const { data } = await apiClient.get('/api/admin/falukant/region-distances');
this.connections = data || [];
} catch (error) {
console.error('Error loading region distances:', error);
this.connections = [];
}
},
rectStyle(rect) {
if (!rect || !this.mapWidth || !this.mapHeight) return {};
// Unterstützt alte absolute Koordinaten (Pixel) und neue relative (01)
const toPxX = (v) => (v >= 0 && v <= 1 ? v * this.mapWidth : v);
const toPxY = (v) => (v >= 0 && v <= 1 ? v * this.mapHeight : v);
const x = toPxX(rect.x);
const y = toPxY(rect.y);
const w = toPxX(rect.w);
const h = toPxY(rect.h);
return {
top: `${y}px`,
left: `${x}px`,
width: `${w}px`,
height: `${h}px`,
};
},
regionClasses(region) {
return {
selected: this.selectedRegion && this.selectedRegion.id === region.id,
dirty: this.dirtyRegionIds.includes(region.id),
source: this.newConnection.sourceRegionId === region.id,
target: this.newConnection.targetRegionId === region.id,
};
},
regionListClasses(region) {
return {
selected: this.selectedRegion && this.selectedRegion.id === region.id,
dirty: this.dirtyRegionIds.includes(region.id),
source: this.newConnection.sourceRegionId === region.id,
target: this.newConnection.targetRegionId === region.id,
};
},
selectRegion(region) {
// Wenn wir im "auf Karte wählen"-Modus sind, setze Quelle/Ziel für die Verbindung
if (this.pickMode === 'source') {
this.newConnection.sourceRegionId = region.id;
this.pickMode = null;
} else if (this.pickMode === 'target') {
this.newConnection.targetRegionId = region.id;
this.pickMode = null;
}
this.selectedRegion = region;
this.selectedRegionDirty = this.dirtyRegionIds.includes(region.id);
this.drawingRect = null;
},
onMouseDown(event) {
// Zeichnen von Rechtecken nur im "Positionen"-Tab
if (this.activeTab !== 'regions') return;
if (!this.selectedRegion) return;
const bounds = this.$refs.mapImage.getBoundingClientRect();
this.startX = event.clientX - bounds.left;
this.startY = event.clientY - bounds.top;
this.currentX = this.startX;
this.currentY = this.startY;
this.updateDrawingRect();
event.preventDefault();
},
onMouseMove(event) {
// Zeichnen nur im "Positionen"-Tab
if (this.activeTab !== 'regions') return;
if (this.startX === null || this.startY === null) return;
const bounds = this.$refs.mapImage.getBoundingClientRect();
this.currentX = event.clientX - bounds.left;
this.currentY = event.clientY - bounds.top;
this.updateDrawingRect();
},
onMouseUp() {
// Im Entfernungen-Tab niemals Koordinaten verändern
if (this.activeTab !== 'regions') {
this.startX = null;
this.startY = null;
this.drawingRect = null;
return;
}
if (!this.selectedRegion || !this.drawingRect) {
this.startX = null;
this.startY = null;
return;
}
// Nur übernehmen, wenn tatsächlich ein Rechteck "aufgezogen" wurde
if (this.drawingRect.w < 3 || this.drawingRect.h < 3) {
this.drawingRect = null;
this.startX = null;
this.startY = null;
return;
}
// Übernehme gezeichnetes Rechteck in ausgewählte Region
const bounds = this.$refs.mapImage.getBoundingClientRect();
const updatedMap = {
x: this.drawingRect.x / bounds.width,
y: this.drawingRect.y / bounds.height,
w: this.drawingRect.w / bounds.width,
h: this.drawingRect.h / bounds.height,
};
this.selectedRegion.map = updatedMap;
this.markRegionDirty(this.selectedRegion.id);
this.startX = null;
this.startY = null;
this.drawingRect = null;
},
updateDrawingRect() {
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 w = Math.abs(this.currentX - this.startX);
const h = Math.abs(this.currentY - this.startY);
this.drawingRect = {
x: Math.round(x),
y: Math.round(y),
w: Math.round(w),
h: Math.round(h),
};
},
markRegionDirty(id) {
if (!this.dirtyRegionIds.includes(id)) {
this.dirtyRegionIds.push(id);
}
if (this.selectedRegion && this.selectedRegion.id === id) {
this.selectedRegionDirty = true;
}
},
async saveRegion(region) {
if (!region || !region.map) return;
await apiClient.put(`/api/admin/falukant/regions/${region.id}/map`, {
map: region.map,
});
// lokal als nicht mehr dirty markieren
this.dirtyRegionIds = this.dirtyRegionIds.filter(id => id !== region.id);
if (this.selectedRegion && this.selectedRegion.id === region.id) {
this.selectedRegionDirty = false;
}
},
async saveSelectedRegion() {
if (!this.selectedRegion || !this.selectedRegion.map) return;
try {
await this.saveRegion(this.selectedRegion);
// Liste aktualisieren, damit andere Einträge auch aktualisierte Daten sehen
await this.loadRegions();
const refreshed = this.regions.find(r => r.id === this.selectedRegion.id);
if (refreshed) {
this.selectedRegion = refreshed;
}
} catch (error) {
console.error('Error saving region map:', error);
}
},
async saveAllRegions() {
if (!this.dirtyRegionIds.length) return;
try {
const dirtyIds = [...this.dirtyRegionIds];
for (const id of dirtyIds) {
const region = this.regions.find(r => r.id === id && r.map);
if (!region) continue;
await this.saveRegion(region);
}
await this.loadRegions();
if (this.selectedRegion) {
const refreshed = this.regions.find(r => r.id === this.selectedRegion.id);
if (refreshed) {
this.selectedRegion = refreshed;
}
}
} catch (error) {
console.error('Error saving all region maps:', error);
}
},
async saveConnection() {
try {
await apiClient.post('/api/admin/falukant/region-distances', {
sourceRegionId: this.newConnection.sourceRegionId,
targetRegionId: this.newConnection.targetRegionId,
transportMode: this.newConnection.transportMode,
distance: this.newConnection.distance,
});
await this.loadConnections();
} catch (error) {
console.error('Error saving region distance:', error);
alert(this.$t('admin.falukant.map.errorSaveConnection'));
}
},
async deleteConnection(id) {
if (!confirm(this.$t('admin.falukant.map.confirmDeleteConnection'))) return;
try {
await apiClient.delete(`/api/admin/falukant/region-distances/${id}`);
await this.loadConnections();
} catch (error) {
console.error('Error deleting region distance:', error);
alert(this.$t('admin.falukant.map.errorDeleteConnection'));
}
},
regionName(id) {
const r = this.regions.find(x => x.id === id);
return r ? r.name : `#${id}`;
},
},
};
</script>
<style scoped>
.falukant-map-admin {
padding: 10px;
}
.map-layout {
display: flex;
gap: 20px;
}
.map-container {
position: relative;
display: inline-block;
}
.map {
max-width: 800px;
border: 1px solid #ccc;
}
.region-rect {
position: absolute;
border: 2px solid rgba(0, 128, 255, 0.7);
background-color: rgba(0, 128, 255, 0.2);
cursor: pointer;
}
.region-rect.selected {
border-color: rgba(255, 128, 0, 0.9);
background-color: rgba(255, 128, 0, 0.25);
}
.region-rect.drawing {
border-style: dashed;
}
.sidebar {
min-width: 260px;
max-width: 320px;
}
.region-list {
list-style: none;
padding: 0;
margin: 0 0 1rem 0;
max-height: 400px;
overflow: auto;
border: 1px solid #ccc;
}
.region-list li {
padding: 4px 8px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}
.region-list li:nth-child(odd) {
background-color: #f8f8f8;
}
.region-list li.selected {
background-color: #e0f0ff;
font-weight: bold;
}
.region-rect.source,
.region-rect.target {
border-color: rgba(0, 160, 0, 0.9);
background-color: rgba(0, 200, 0, 0.35);
}
.region-list li.source,
.region-list li.target {
background-color: #d2f5d2;
}
.region-list li.dirty {
font-style: italic;
}
.coords {
font-size: 0.8rem;
color: #555;
}
.coords.missing {
color: #b00;
}
.details {
margin-top: 1rem;
}
.hint {
font-size: 0.85rem;
color: #555;
margin-bottom: 0.5rem;
}
.connection-form-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 0.5rem;
}
.connection-form-table th,
.connection-form-table td {
padding: 2px 4px;
vertical-align: middle;
}
.label-cell {
width: 1%;
white-space: nowrap;
font-weight: 600;
}
.field-cell {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
}
.field-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
}
.connection-actions-cell {
text-align: right;
}
.btn.mini {
padding: 2px 6px;
font-size: 0.8rem;
}
.btn.mini.icon {
}
</style>

View File

@@ -29,7 +29,13 @@
<!-- Inventar / Verkauf -->
<div v-else-if="activeTab === 'inventory'" class="branch-tab-pane">
<SaleSection :branchId="selectedBranch.id" ref="saleSection" />
<SaleSection
:branchId="selectedBranch.id"
:vehicles="vehicles"
:branches="branches"
ref="saleSection"
@transportCreated="handleTransportCreated"
/>
</div>
<!-- Produktion + Produkt-Erträge -->
@@ -58,6 +64,48 @@
<button @click="openBuyVehicleDialog">
{{ $t('falukant.branch.transport.buy') }}
</button>
<div class="vehicle-overview" v-if="vehicles && vehicles.length">
<table class="vehicle-table">
<thead>
<tr>
<th>{{ $t('falukant.branch.transport.table.type') }}</th>
<th>{{ $t('falukant.branch.transport.table.capacity') }}</th>
<th>{{ $t('falukant.branch.transport.table.condition') }}</th>
<th>{{ $t('falukant.branch.transport.table.mode') }}</th>
<th>{{ $t('falukant.branch.transport.table.speed') }}</th>
<th>{{ $t('falukant.branch.transport.table.availableFrom') }}</th>
<th>{{ $t('falukant.branch.transport.table.status') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="v in vehicles" :key="v.id">
<td>
{{ $t(`falukant.branch.vehicles.${v.type.tr}`) }}
</td>
<td>{{ v.type.capacity }}</td>
<td>{{ conditionLabel(v.condition) }}</td>
<td>{{ v.type.transportMode }}</td>
<td>{{ v.type.speed }}</td>
<td>{{ formatDateTime(v.availableFrom) }}</td>
<td>
<span v-if="v.status === 'travelling'">
{{ $t('falukant.branch.transport.status.inUse') }}
</span>
<span v-else-if="v.status === 'building'">
{{ $t('falukant.branch.transport.status.building') }}
</span>
<span v-else>
{{ $t('falukant.branch.transport.status.free') }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
<p v-else class="no-vehicles">
{{ $t('falukant.branch.transport.noVehicles') }}
</p>
</div>
</div>
<BuyVehicleDialog
@@ -102,6 +150,7 @@ export default {
branches: [],
selectedBranch: null,
products: [],
vehicles: [],
activeTab: 'production',
tabs: [
{ value: 'production', label: 'falukant.branch.tabs.production' },
@@ -175,6 +224,7 @@ export default {
regionId: branch.regionId,
cityName: branch.region.name,
type: this.$t(`falukant.branch.types.${branch.branchType.labelTr}`),
branchTypeLabelTr: branch.branchType.labelTr,
isMainBranch: branch.isMainBranch,
}));
if (!this.selectedBranch) {
@@ -197,9 +247,11 @@ export default {
async onBranchSelected(newBranch) {
this.selectedBranch = newBranch;
await this.loadProducts();
await this.loadVehicles();
this.$nextTick(() => {
this.$refs.directorInfo?.refresh();
this.$refs.saleSection?.loadInventory();
this.$refs.saleSection?.loadTransports();
this.$refs.productionSection?.loadProductions();
this.$refs.storageSection?.loadStorageData();
this.$refs.revenueSection?.refresh && this.$refs.revenueSection.refresh();
@@ -226,14 +278,21 @@ export default {
}
},
upgradeBranch() {
if (this.selectedBranch) {
alert(
this.$t(
'falukant.branch.actions.upgradeAlert',
{ branchId: this.selectedBranch.id }
)
);
async upgradeBranch() {
if (!this.selectedBranch) return;
try {
await apiClient.post('/api/falukant/branches/upgrade', {
branchId: this.selectedBranch.id,
});
await this.loadBranches();
// Ausgewählten Branch nach dem Upgrade neu setzen
const updated = this.branches.find(b => b.id === this.selectedBranch.id);
if (updated) {
await this.onBranchSelected(updated);
}
} catch (error) {
console.error('Error upgrading branch:', error);
alert(this.$t('falukant.branch.actions.upgradeAlert', { branchId: this.selectedBranch.id }));
}
},
@@ -242,6 +301,9 @@ export default {
if (main && main !== this.selectedBranch) {
this.selectedBranch = main;
}
if (this.selectedBranch) {
this.loadVehicles();
}
if (this.selectedBranch && !this.activeTab) {
this.activeTab = 'director';
}
@@ -280,6 +342,38 @@ export default {
};
},
conditionLabel(value) {
const v = Number(value) || 0;
if (v >= 95) return 'Ausgezeichnet'; // 95100
if (v >= 72) return 'Sehr gut'; // 7294
if (v >= 54) return 'Gut'; // 5471
if (v >= 39) return 'Mäßig'; // 3953
if (v >= 22) return 'Schlecht'; // 2238
if (v >= 6) return 'Sehr schlecht'; // 621
if (v >= 1) return 'Katastrophal'; // 15
return 'Unbekannt';
},
async loadVehicles() {
if (!this.selectedBranch) return;
try {
const { data } = await apiClient.get('/api/falukant/vehicles', {
params: { regionId: this.selectedBranch.regionId },
});
this.vehicles = data || [];
} catch (error) {
console.error('Error loading vehicles:', error);
this.vehicles = [];
}
},
formatDateTime(value) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return date.toLocaleString();
},
handleEvent(eventData) {
switch (eventData.event) {
case 'production_ready':
@@ -352,6 +446,12 @@ export default {
handleVehiclesBought() {
// Refresh status bar (for updated money) and potentially other data later
this.$refs.statusBar?.fetchStatus();
this.loadVehicles();
},
handleTransportCreated() {
this.loadVehicles();
this.$refs.storageSection?.loadStorageData();
this.$refs.saleSection?.loadTransports();
},
},
};

View File

@@ -17,7 +17,7 @@
<tbody>
<tr v-for="(value, key) in status" :key="key">
<td>{{ $t(`falukant.house.status.${key}`) }}</td>
<td>{{ value }}%</td>
<td>{{ conditionLabel(value) }}</td>
<td>
<button v-if="value < 100" @click="renovate(key)">
{{ $t('falukant.house.renovate') }} ({{ getRenovationCost(key, value) }})
@@ -54,7 +54,7 @@
<tr v-for="(val, prop) in house" :key="prop"
v-if="['roofCondition', 'wallCondition', 'floorCondition', 'windowCondition'].includes(prop)">
<td>{{ $t(`falukant.house.status.${prop}`) }}</td>
<td>{{ val }}%</td>
<td>{{ conditionLabel(val) }}</td>
</tr>
</tbody>
</table>
@@ -109,6 +109,17 @@ export default {
console.error('Error loading house data', err);
}
},
conditionLabel(value) {
const v = Number(value) || 0;
if (v >= 95) return 'Ausgezeichnet'; // 95100
if (v >= 72) return 'Sehr gut'; // 7294
if (v >= 54) return 'Gut'; // 5471
if (v >= 39) return 'Mäßig'; // 3953
if (v >= 22) return 'Schlecht'; // 2238
if (v >= 6) return 'Sehr schlecht'; // 621
if (v >= 1) return 'Katastrophal'; // 15
return 'Unbekannt';
},
houseStyle(position, picSize) {
const columns = 3;
const size = picSize;

View File

@@ -32,7 +32,7 @@
<span v-if="!isAdvancing">{{ $t('falukant.nobility.advance.confirm') }}</span>
<span v-else>{{ $t('falukant.nobility.advance.processing') }}</span>
</button>
<span>-&gt;{{ canAdvance }}, {{ isAdvancing }}&lt;-</span>
<!-- debug state output removed -->
</div>
</div>
@@ -109,7 +109,7 @@
if (!this.canAdvance || this.isAdvancing) return;
this.isAdvancing = true;
try {
await apiClient.post('/api/falukant/nobility/advance');
await apiClient.post('/api/falukant/nobility');
await this.loadNobility();
} catch (err) {
console.error('Error advancing nobility:', err);
@@ -124,9 +124,6 @@
},
formatCost(val) {
return new Intl.NumberFormat(navigator.language, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(val);
},
async applyAdvance() {
await apiClient.post('/api/falukant/nobility');
}
}
};