Files
yourpart3/frontend/src/views/falukant/BranchView.vue

1585 lines
62 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="falukant-branch-view">
<StatusBar ref="statusBar" />
<div class="falukant-branch">
<section class="branch-hero surface-card">
<div>
<span class="branch-kicker">Niederlassung</span>
<h2>{{ $t('falukant.branch.title') }}</h2>
<p>Produktion, Lager, Verkauf und Transport in einer spielweltbezogenen Steuerfläche.</p>
<div class="branch-hero__meta">
<span class="branch-hero__badge">
{{ $t('falukant.branch.currentCertificate') }}: {{ currentCertificate ?? '---' }}
</span>
</div>
</div>
</section>
<section class="branch-certificate surface-card">
<div class="branch-certificate__header">
<div>
<h3>{{ $t('falukant.branch.certificate.title') }}</h3>
<p>{{ $t('falukant.branch.certificate.description') }}</p>
</div>
<span class="branch-hero__badge">
{{ $t('falukant.branch.currentCertificate') }}: {{ currentCertificate ?? 1 }}
</span>
</div>
<div class="branch-certificate__grid">
<article class="branch-certificate__block">
<h4>{{ $t('falukant.branch.certificate.currentUnlocks') }}</h4>
<p>{{ currentCertificateProducts.join(', ') }}</p>
</article>
<article v-if="nextCertificateProducts.length" class="branch-certificate__block">
<h4>{{ $t('falukant.branch.certificate.nextUnlocks', { level: nextCertificateLevel }) }}</h4>
<p>{{ nextCertificateProducts.join(', ') }}</p>
</article>
</div>
</section>
<section
v-if="debtorsPrison.active"
class="branch-debt-warning surface-card"
:class="{ 'is-prison': debtorsPrison.inDebtorsPrison }"
>
<strong>
{{ debtorsPrison.inDebtorsPrison
? $t('falukant.bank.debtorsPrison.titlePrison')
: $t('falukant.bank.debtorsPrison.titleWarning') }}
</strong>
<p>
{{ debtorsPrison.inDebtorsPrison
? $t('falukant.branch.debtorsPrison.branchLocked')
: $t('falukant.branch.debtorsPrison.branchRisk') }}
</p>
</section>
<BranchSelection
:branches="branches"
:selectedBranch="selectedBranch"
:blocked="debtorsPrison.inDebtorsPrison"
:blocked-reason="debtorsPrison.inDebtorsPrison ? $t('falukant.branch.debtorsPrison.selectionBlocked') : ''"
@branchSelected="onBranchSelected"
@createBranch="createBranch"
@upgradeBranch="upgradeBranch"
ref="branchSelection"
/>
<!-- Tab-Navigation für Inhalte der ausgewählten Niederlassung -->
<SimpleTabs
v-if="selectedBranch"
v-model="activeTab"
:tabs="tabs"
/>
<!-- Tab-Inhalte -->
<div v-if="selectedBranch" class="branch-tab-content">
<!-- Direktor -->
<div v-if="activeTab === 'director'" class="branch-tab-pane">
<DirectorInfo
:branchId="selectedBranch.id"
:vehicles="vehicles"
:branches="branches"
ref="directorInfo"
@transportCreated="handleTransportCreated"
/>
</div>
<!-- Inventar / Verkauf -->
<div v-else-if="activeTab === 'inventory'" class="branch-tab-pane">
<!-- Tax summary for inventory/sales -->
<div v-if="branchTaxes" class="branch-tax-summary">
<strong>{{ $t('falukant.branch.taxes.total') }}:</strong>
<span>{{ formatPercent(branchTaxes.total) }}</span>
</div>
<SaleSection
:branchId="selectedBranch.id"
:vehicles="vehicles"
:branches="branches"
ref="saleSection"
@transportCreated="handleTransportCreated"
/>
</div>
<!-- Produktion + Produkt-Erträge -->
<div v-else-if="activeTab === 'production'" class="branch-tab-pane">
<ProductionSection
:branchId="selectedBranch.id"
:products="products"
:current-certificate="currentCertificate"
ref="productionSection"
/>
<!-- Tax summary for production -->
<div v-if="branchTaxes" class="branch-tax-summary">
<strong>{{ $t('falukant.branch.taxes.total') }}:</strong>
<span>{{ formatPercent(branchTaxes.total) }}</span>
</div>
<RevenueSection
:products="products"
:calculateProductRevenue="calculateProductRevenue"
:calculateProductProfit="calculateProductProfit"
:currentRegionId="selectedBranch?.regionId"
ref="revenueSection"
/>
</div>
<!-- Taxes Übersicht -->
<div v-else-if="activeTab === 'taxes'" class="branch-tab-pane">
<h3>{{ $t('falukant.branch.taxes.title') }}</h3>
<div v-if="branchTaxesLoading">
<p>{{ $t('falukant.branch.taxes.loading') }}</p>
</div>
<div v-else-if="branchTaxesError">
<p>{{ $t('falukant.branch.taxes.loadingError', { error: branchTaxesError }) }}</p>
<button @click="loadBranchTaxes">{{ $t('falukant.branch.taxes.retry') }}</button>
</div>
<div v-else-if="!branchTaxes || !branchTaxes.breakdown || branchTaxes.breakdown.length === 0">
<p>{{ $t('falukant.branch.taxes.noData') }}</p>
</div>
<div v-else>
<p>
<strong>{{ $t('falukant.branch.taxes.total') }}:</strong>
<span>{{ formatCurrency(branchTaxes.total) }}</span>
</p>
<table class="taxes-table">
<thead>
<tr>
<th>{{ $t('falukant.branch.taxes.table.region') }}</th>
<th>{{ $t('falukant.branch.taxes.table.taxPercent') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="r in branchTaxes.breakdown" :key="r.id">
<td>{{ r.name }}</td>
<td>{{ r.taxPercent }}%</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Lager -->
<div v-else-if="activeTab === 'storage'" class="branch-tab-pane">
<StorageSection :branchId="selectedBranch.id" ref="storageSection" />
</div>
<!-- Transportmittel -->
<div v-else-if="activeTab === 'transport'" class="branch-tab-pane">
<p>{{ $t('falukant.branch.transport.placeholder') }}</p>
<div class="vehicle-action-buttons">
<button @click="openBuyVehicleDialog">
{{ $t('falukant.branch.transport.buy') }}
</button>
<button
v-if="repairableVehiclesCount > 0"
@click="openRepairAllVehiclesDialog"
class="repair-all-button"
>
{{ $t('falukant.branch.transport.repairAll', { count: repairableVehiclesCount, cost: formatMoney(repairAllCost) }) }}
</button>
</div>
<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>
<th>{{ $t('falukant.branch.transport.table.actions') }}</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>{{ transportModeLabel(v.type.transportMode) }}</td>
<td>{{ speedLabel(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>
<td>
<div class="vehicle-actions">
<button
v-if="v.status === 'available'"
@click="openSendVehicleDialog(v.id)"
:title="$t('falukant.branch.transport.sendSingle')"
>
{{ $t('falukant.branch.transport.send') }}
</button>
<button
v-if="v.status === 'available' && v.condition < 100"
@click="openRepairVehicleDialog(v)"
:title="$t('falukant.branch.transport.repairTooltip')"
class="repair-button"
>
{{ $t('falukant.branch.transport.repairWithCost', { cost: formatMoney(calculateRepairCost(v)) }) }}
</button>
<span v-if="v.status !== 'available'" class="no-action"></span>
</div>
</td>
</tr>
</tbody>
</table>
<!-- Buttons zum Versenden aller freien Fahrzeuge eines Typs -->
<div class="send-all-vehicles" v-if="freeVehiclesByType && Object.keys(freeVehiclesByType).length > 0">
<h4>{{ $t('falukant.branch.transport.sendAllFree') }}</h4>
<div class="send-all-buttons">
<button
v-for="(vehicleList, vehicleTypeId) in freeVehiclesByType"
:key="vehicleTypeId"
@click="openSendAllVehiclesDialog(parseInt(vehicleTypeId), vehicleList)"
>
{{ $t('falukant.branch.transport.sendAllOfType', {
type: $t(`falukant.branch.vehicles.${vehicleList[0].type.tr}`),
count: vehicleList.length
}) }}
</button>
</div>
</div>
</div>
<p v-else class="no-vehicles">
{{ $t('falukant.branch.transport.noVehicles') }}
</p>
</div>
</div>
<BuyVehicleDialog
v-if="selectedBranch"
ref="buyVehicleDialog"
:region-id="selectedBranch?.regionId"
@bought="handleVehiclesBought"
/>
<!-- Dialog zum Versenden von Fahrzeugen -->
<div v-if="sendVehicleDialog.show" class="modal-overlay" @click.self="closeSendVehicleDialog">
<div class="modal-content">
<h3>{{ $t('falukant.branch.transport.sendVehiclesTitle') }}</h3>
<div v-if="!sendVehicleDialog.success" class="send-vehicle-form">
<label>
{{ $t('falukant.branch.transport.selectTargetBranch') }}
<select v-model.number="sendVehicleDialog.targetBranchId">
<option :value="null" disabled>{{ $t('falukant.branch.transport.selectTarget') }}</option>
<option v-for="tb in targetBranchOptions()" :key="tb.id" :value="tb.id">
{{ tb.label }}
</option>
</select>
</label>
<label>
{{ $t('falukant.branch.transport.guardCount') }}
<input v-model.number="sendVehicleDialog.guardCount" type="number" min="0" max="20" />
</label>
<p class="transport-guards-hint">
{{ $t('falukant.branch.transport.guardHint', { cost: formatMoney((sendVehicleDialog.guardCount || 0) * 4) }) }}
</p>
<div class="modal-buttons">
<button @click="sendVehicles" :disabled="!sendVehicleDialog.targetBranchId">
{{ $t('falukant.branch.transport.send') }}
</button>
<button @click="closeSendVehicleDialog">
{{ $t('falukant.branch.transport.cancel') }}
</button>
</div>
</div>
<div v-else class="send-vehicle-success">
<p>{{ $t('falukant.branch.transport.sendSuccess') }}</p>
<div class="modal-buttons">
<button @click="closeSendVehicleDialog">
{{ $t('falukant.branch.transport.close') }}
</button>
</div>
</div>
</div>
</div>
<!-- Dialog zum Reparieren aller Fahrzeuge -->
<div v-if="repairAllVehiclesDialog.show" class="modal-overlay" @click.self="closeRepairAllVehiclesDialog">
<div class="modal-content">
<h3>{{ $t('falukant.branch.transport.repairAllTitle') }}</h3>
<div class="repair-all-form">
<p>
{{ $t('falukant.branch.transport.repairAllDescription', {
count: repairAllVehiclesDialog.vehicleIds.length,
cost: formatMoney(repairAllVehiclesDialog.totalCost)
}) }}
</p>
<p class="repair-all-discount">
{{ $t('falukant.branch.transport.repairAllDiscount') }}
</p>
<div class="modal-buttons">
<button @click="repairAllVehicles" class="repair-confirm-button">
{{ $t('falukant.branch.transport.repairAllConfirm') }}
</button>
<button @click="closeRepairAllVehiclesDialog">
{{ $t('falukant.branch.transport.cancel') }}
</button>
</div>
</div>
</div>
</div>
<!-- Dialog zum Reparieren von Fahrzeugen -->
<div v-if="repairVehicleDialog.show" class="modal-overlay" @click.self="closeRepairVehicleDialog">
<div class="modal-content">
<h3>{{ $t('falukant.branch.transport.repairVehicleTitle') }}</h3>
<div class="repair-vehicle-form" v-if="repairVehicleDialog.vehicle">
<div class="repair-info">
<p>
<strong>{{ $t('falukant.branch.transport.repairVehicleType') }}:</strong>
{{ $t(`falukant.branch.vehicles.${repairVehicleDialog.vehicle.type.tr}`) }}
</p>
<p>
<strong>{{ $t('falukant.branch.transport.repairCurrentCondition') }}:</strong>
{{ repairVehicleDialog.vehicle.condition }}%
</p>
<p>
<strong>{{ $t('falukant.branch.transport.repairCost') }}:</strong>
{{ formatMoney(repairVehicleDialog.repairCost) }}
</p>
<p>
<strong>{{ $t('falukant.branch.transport.repairBuildTime') }}:</strong>
{{ formatBuildTime(repairVehicleDialog.buildTimeMinutes) }}
</p>
</div>
<div class="modal-buttons">
<button @click="repairVehicle" class="repair-confirm-button">
{{ $t('falukant.branch.transport.repairConfirm') }}
</button>
<button @click="closeRepairVehicleDialog">
{{ $t('falukant.branch.transport.cancel') }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import StatusBar from '@/components/falukant/StatusBar.vue';
import BranchSelection from '@/components/falukant/BranchSelection.vue';
import SimpleTabs from '@/components/SimpleTabs.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 BuyVehicleDialog from '@/dialogues/falukant/BuyVehicleDialog.vue';
import apiClient from '@/utils/axios.js';
import { mapState } from 'vuex';
import { showError, showSuccess, showApiError } from '@/utils/feedback.js';
const CERTIFICATE_PRODUCT_LEVELS = [
{ level: 1, products: ['fish', 'meat', 'leather', 'wood', 'stone', 'milk', 'cheese', 'bread', 'wheat', 'grain', 'carrot'] },
{ level: 2, products: ['beer', 'iron', 'copper', 'spices', 'salt', 'sugar', 'vinegar', 'cotton', 'wine'] },
{ level: 3, products: ['gold', 'diamond', 'furniture', 'clothing'] },
{ level: 4, products: ['jewelry', 'painting', 'book', 'weapon', 'armor', 'shield'] },
{ level: 5, products: ['horse', 'ox'] },
];
// Stückkosten wie backend/utils/falukant/falukantProductEconomy.js (bei Änderungen dort mitziehen).
const PRODUCTION_COST_BASE = 6.0;
const PRODUCTION_COST_PER_PRODUCT_CATEGORY = 1.0;
const PRODUCTION_HEADROOM_DISCOUNT_PER_STEP = 0.035;
const PRODUCTION_HEADROOM_DISCOUNT_CAP = 0.14;
export default {
name: "BranchView",
components: {
StatusBar,
BranchSelection,
SimpleTabs,
DirectorInfo,
SaleSection,
ProductionSection,
StorageSection,
RevenueSection,
BuyVehicleDialog,
},
watch: {
// Wenn sich der Daemon-Socket ändert (z.B. nach Login/Reconnect),
// Listener sauber entfernen/neu registrieren.
daemonSocket(newSocket, oldSocket) {
if (oldSocket) {
oldSocket.removeEventListener('message', this.handleDaemonMessage);
}
if (newSocket) {
newSocket.addEventListener('message', this.handleDaemonMessage);
}
}
},
data() {
return {
branches: [],
selectedBranch: null,
products: [],
vehicles: [],
activeTab: 'production',
productPricesCache: {}, // Cache für regionale Preise: { productId: price }
productPricesCacheRegionId: null, // regionId, für die der Cache gültig ist
tabs: [
{ value: 'production', label: 'falukant.branch.tabs.production' },
{ value: 'inventory', label: 'falukant.branch.tabs.inventory' },
{ value: 'taxes', label: 'falukant.branch.tabs.taxes' },
{ value: 'director', label: 'falukant.branch.tabs.director' },
{ value: 'storage', label: 'falukant.branch.tabs.storage' },
{ value: 'transport', label: 'falukant.branch.tabs.transport' },
],
sendVehicleDialog: {
show: false,
vehicleId: null,
vehicleIds: null,
vehicleTypeId: null,
targetBranchId: null,
success: false,
guardCount: 0,
},
repairVehicleDialog: {
show: false,
vehicle: null,
repairCost: null,
buildTimeMinutes: null,
},
repairAllVehiclesDialog: {
show: false,
vehicleIds: [],
totalCost: null,
},
branchTaxes: null,
branchTaxesLoading: false,
branchTaxesError: null,
currentCertificate: null,
debtorsPrison: {
active: false,
inDebtorsPrison: false
},
pendingBranchRefresh: null,
};
},
computed: {
...mapState(['socket', 'daemonSocket', 'user']),
freeVehiclesByType() {
const grouped = {};
for (const v of this.vehicles || []) {
if (v.status !== 'available' || !v.type || !v.type.id) continue;
const typeId = v.type.id;
if (!grouped[typeId]) {
grouped[typeId] = [];
}
grouped[typeId].push(v);
}
return grouped;
},
repairableVehicles() {
return (this.vehicles || []).filter(v =>
v.status === 'available' && v.condition < 100
);
},
repairableVehiclesCount() {
return this.repairableVehicles.length;
},
repairAllCost() {
const totalCost = this.repairableVehicles.reduce((sum, v) => {
return sum + this.calculateRepairCost(v);
}, 0);
// 10% Rabatt für Reparatur aller Fahrzeuge
return Math.round(totalCost * 0.9);
},
currentCertificateProducts() {
const certificate = Number(this.currentCertificate || 1);
return CERTIFICATE_PRODUCT_LEVELS
.filter((entry) => entry.level <= certificate)
.flatMap((entry) => entry.products)
.map((productKey) => this.$t(`falukant.product.${productKey}`));
},
nextCertificateLevel() {
const current = Number(this.currentCertificate || 1);
return current < CERTIFICATE_PRODUCT_LEVELS.length ? current + 1 : null;
},
nextCertificateProducts() {
const nextEntry = CERTIFICATE_PRODUCT_LEVELS.find((entry) => entry.level === this.nextCertificateLevel);
return nextEntry
? nextEntry.products.map((productKey) => this.$t(`falukant.product.${productKey}`))
: [];
},
},
async mounted() {
await this.loadBranches();
const branchId = this.$route.params.branchId;
await this.loadCurrentCertificate();
await this.loadProducts();
if (branchId) {
this.selectedBranch = this.branches.find(
b => b.id === parseInt(branchId, 10)
) || null;
} else {
this.selectMainBranch();
}
// Live-Socket-Events (Daemon WS)
if (this.daemonSocket) {
this.daemonSocket.addEventListener('message', this.handleDaemonMessage);
}
// Live-Socket-Events (Backend Socket.io)
if (this.socket) {
this.socket.on('falukantUpdateStatus', (data) => this.handleEvent({ event: 'falukantUpdateStatus', ...data }));
this.socket.on('falukantUpdateDebt', (data) => this.handleEvent({ event: 'falukantUpdateDebt', ...data }));
this.socket.on('falukantUpdateProductionCertificate', (data) => this.handleEvent({ event: 'falukantUpdateProductionCertificate', ...data }));
this.socket.on('falukantBranchUpdate', (data) => this.handleEvent({ event: 'falukantBranchUpdate', ...data }));
this.socket.on('transport_arrived', (data) => this.handleEvent({ event: 'transport_arrived', ...data }));
this.socket.on('inventory_updated', (data) => this.handleEvent({ event: 'inventory_updated', ...data }));
}
// Load taxes for the initially selected branch (if any)
await this.loadBranchTaxes();
},
beforeUnmount() {
if (this.pendingBranchRefresh) {
clearTimeout(this.pendingBranchRefresh);
this.pendingBranchRefresh = null;
}
// Daemon WebSocket: Listener entfernen (der Socket selbst wird beim Logout geschlossen)
if (this.daemonSocket) {
this.daemonSocket.removeEventListener('message', this.handleDaemonMessage);
}
if (this.socket) {
this.socket.off('falukantUpdateStatus');
this.socket.off('falukantUpdateDebt');
this.socket.off('falukantUpdateProductionCertificate');
this.socket.off('falukantBranchUpdate');
this.socket.off('transport_arrived');
this.socket.off('inventory_updated');
}
},
watch: {
activeTab(newVal) {
if (newVal === 'taxes') {
this.loadBranchTaxes();
}
},
selectedBranch: {
handler(newBranch) {
// if taxes tab is active, refresh when branch changes
if (this.activeTab === 'taxes' && newBranch && newBranch.id) {
this.loadBranchTaxes();
}
},
deep: false
}
},
methods: {
calculateProductionPieceCost(productCategory) {
const category = Math.max(1, Number(productCategory) || 1);
const certificate = Math.max(1, Number(this.currentCertificate) || 1);
const raw = PRODUCTION_COST_BASE + (category * PRODUCTION_COST_PER_PRODUCT_CATEGORY);
const headroom = Math.max(0, certificate - category);
const discount = Math.min(
headroom * PRODUCTION_HEADROOM_DISCOUNT_PER_STEP,
PRODUCTION_HEADROOM_DISCOUNT_CAP
);
return raw * (1 - discount);
},
matchesCurrentUser(eventData) {
if (eventData?.user_id == null) {
return true;
}
const currentIds = [this.user?.id, this.user?.hashedId]
.filter(Boolean)
.map((value) => String(value));
return currentIds.includes(String(eventData.user_id));
},
queueBranchRefresh() {
if (this.pendingBranchRefresh) {
clearTimeout(this.pendingBranchRefresh);
}
this.pendingBranchRefresh = setTimeout(async () => {
this.pendingBranchRefresh = null;
this.$refs.statusBar?.fetchStatus();
await this.loadCurrentCertificate();
await this.loadProducts();
this.$refs.productionSection?.loadProductions();
this.$refs.productionSection?.loadStorage();
this.$refs.storageSection?.loadStorageData();
this.$refs.saleSection?.loadInventory();
if (this.$refs.revenueSection) {
this.$refs.revenueSection.products = this.products;
this.$refs.revenueSection.refresh && this.$refs.revenueSection.refresh();
}
}, 120);
},
async loadBranches() {
try {
const result = await apiClient.get('/api/falukant/branches');
this.branches = result.data.map(branch => ({
id: branch.id,
regionId: branch.regionId,
cityName: branch.region.name,
type: this.$t(`falukant.branch.types.${branch.branchType.labelTr}`),
branchTypeLabelTr: branch.branchType.labelTr,
isMainBranch: branch.isMainBranch,
weather: branch.weather,
}));
if (!this.selectedBranch) {
this.selectMainBranch();
}
} catch (error) {
console.error('Error loading branches:', error);
}
},
async loadCurrentCertificate() {
try {
const result = await apiClient.get('/api/falukant/user');
this.currentCertificate = result.data?.certificate ?? null;
this.debtorsPrison = result.data?.debtorsPrison || {
active: false,
inDebtorsPrison: false
};
} catch (error) {
console.error('Error loading certificate:', error);
}
},
async loadProducts() {
try {
const productsResult = await apiClient.get('/api/falukant/products');
this.products = productsResult.data;
} catch (error) {
console.error('Error loading products:', error);
}
},
async loadBranchTaxes() {
if (!this.selectedBranch || !this.selectedBranch.id) {
this.branchTaxes = null;
this.branchTaxesError = null;
this.branchTaxesLoading = false;
return;
}
this.branchTaxesLoading = true;
this.branchTaxesError = null;
try {
const res = await apiClient.get(`/api/falukant/branches/${this.selectedBranch.id}/taxes`);
this.branchTaxes = res.data;
} catch (err) {
console.error('Failed to load branch taxes', err);
// Try to surface a useful error message
const remoteMsg = err?.response?.data?.error || err?.message || String(err);
this.branchTaxes = null;
this.branchTaxesError = remoteMsg;
} finally {
this.branchTaxesLoading = false;
}
},
async onBranchSelected(newBranch) {
this.selectedBranch = newBranch;
// Branches neu laden, um das Wetter zu aktualisieren
await this.loadBranches();
// Den ausgewählten Branch nach dem Neuladen wieder setzen
if (newBranch) {
this.selectedBranch = this.branches.find(b => b.id === newBranch.id) || newBranch;
}
await this.loadProducts();
await this.loadVehicles();
await this.loadProductPricesForCurrentBranch();
this.$nextTick(() => {
this.$refs.directorInfo?.refresh();
this.$refs.saleSection?.loadInventory();
this.$refs.saleSection?.loadTransports();
this.$refs.productionSection?.loadProductions();
this.$refs.productionSection?.loadStorage();
this.$refs.storageSection?.loadStorageData();
this.$refs.revenueSection?.refresh && this.$refs.revenueSection.refresh();
});
// load tax info for this branch
this.loadBranchTaxes();
// Beim Initial-Laden sicherstellen, dass ein Tab-Inhalt sichtbar ist
if (this.selectedBranch && !this.activeTab) {
this.activeTab = 'director';
}
},
async loadProductPricesForCurrentBranch() {
if (!this.selectedBranch || !this.selectedBranch.regionId) {
this.productPricesCache = {};
this.productPricesCacheRegionId = null;
return;
}
if (this.productPricesCacheRegionId === this.selectedBranch.regionId && Object.keys(this.productPricesCache).length > 0) {
return;
}
try {
const { data } = await apiClient.get('/api/falukant/products/prices-in-region', {
params: {
regionId: this.selectedBranch.regionId
}
});
this.productPricesCache = data.prices || {};
this.productPricesCacheRegionId = this.selectedBranch.regionId;
} catch (error) {
console.error(`Error loading product prices for region ${this.selectedBranch.regionId}:`, error);
// Fallback: Lade Preise einzeln (alte Methode)
console.warn('[BranchView] Falling back to individual product price requests');
const prices = {};
for (const product of this.products) {
try {
const { data } = await apiClient.get('/api/falukant/products/price-in-region', {
params: {
productId: product.id,
regionId: this.selectedBranch.regionId
}
});
prices[product.id] = data.price;
} catch (err) {
console.error(`Error loading price for product ${product.id}:`, err);
// Fallback auf Standard-Berechnung
const knowledgeFactor = product.knowledges?.[0]?.knowledge || 0;
const maxPrice = product.sellCost;
const minPrice = maxPrice * 0.7;
prices[product.id] = minPrice + (maxPrice - minPrice) * (knowledgeFactor / 100);
}
}
this.productPricesCache = prices;
this.productPricesCacheRegionId = this.selectedBranch?.regionId ?? null;
}
},
formatPercent(value) {
if (value === null || value === undefined) return '—';
// Ensure numeric and format with locale with max 2 fraction digits
const num = typeof value === 'string' ? parseFloat(value) : Number(value);
if (Number.isNaN(num)) return '—';
return num.toLocaleString(undefined, { maximumFractionDigits: 2 }) + '%';
},
async createBranch() {
if (this.debtorsPrison.inDebtorsPrison) {
showError(this, this.$t('falukant.branch.debtorsPrison.selectionBlocked'));
return;
}
await this.loadBranches();
// Nach dem Anlegen eines neuen Branches automatisch den
// zuletzt/neu erstellten Branch auswählen.
if (this.branches.length > 0) {
const newest = this.branches.reduce((acc, b) =>
!acc || b.id > acc.id ? b : acc,
null
);
if (newest) {
await this.onBranchSelected(newest);
}
}
},
async upgradeBranch() {
if (!this.selectedBranch) return;
if (this.debtorsPrison.inDebtorsPrison) {
showError(this, this.$t('falukant.branch.debtorsPrison.selectionBlocked'));
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);
showError(this, 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;
}
if (this.selectedBranch) {
this.loadVehicles();
}
if (this.selectedBranch && !this.activeTab) {
this.activeTab = 'director';
}
},
calculateProductRevenue(product) {
if (!product.knowledges || product.knowledges.length === 0) {
return { absolute: 0, perMinute: 0 };
}
// Verwende gecachten regionalen Preis, falls verfügbar
let revenuePerUnit;
if (this.productPricesCache[product.id] !== undefined) {
revenuePerUnit = this.productPricesCache[product.id];
} else {
// Fallback auf Standard-Berechnung
const knowledgeFactor = product.knowledges[0].knowledge || 0;
const maxPrice = product.sellCost;
const minPrice = maxPrice * 0.7;
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 = this.calculateProductionPieceCost(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),
};
},
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';
},
speedLabel(value) {
if (value == null) return this.$t('falukant.branch.transport.speed.unknown') || '—';
if (typeof value === 'object') {
const k = value.tr ?? value.id ?? 'unknown';
const tKey = `falukant.branch.transport.speed.${k}`;
const t = this.$t(tKey);
return (t && t !== tKey) ? t : String(k);
}
const key = String(value);
const tKey = `falukant.branch.transport.speed.${key}`;
const translated = this.$t(tKey);
return (!translated || translated === tKey) ? key : translated;
},
transportModeLabel(mode) {
if (!mode) return '';
const key = String(mode);
const tKey = `falukant.branch.transport.modes.${key}`;
const translated = this.$t(tKey);
if (!translated || translated === tKey) return mode;
return translated;
},
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) {
if (!this.matchesCurrentUser(eventData)) {
return;
}
switch (eventData.event) {
case 'production_ready':
this.$refs.productionSection?.loadProductions();
this.$refs.storageSection?.loadStorageData();
this.$refs.productionSection?.loadStorage();
this.$refs.saleSection?.loadInventory();
break;
case 'stock_change':
this.$refs.storageSection?.loadStorageData();
this.$refs.productionSection?.loadStorage();
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();
this.$refs.productionSection?.loadStorage();
break;
case 'falukantUpdateStatus':
case 'falukantUpdateDebt':
case 'falukantUpdateProductionCertificate':
case 'falukantBranchUpdate':
this.queueBranchRefresh();
break;
case 'knowledge_update':
this.queueBranchRefresh();
break;
case 'transport_arrived':
// Leerer Transport angekommen - Fahrzeug wurde zurückgeholt
if (eventData.empty && eventData.branch_id) {
// Nur aktualisieren, wenn der betroffene Branch ausgewählt ist
if (this.selectedBranch && this.selectedBranch.id === eventData.branch_id) {
// Fahrzeuge im Transport-Tab aktualisieren
this.loadVehicles();
// Laufende Transporte im Inventar-Tab aktualisieren
if (this.$refs.saleSection) {
this.$refs.saleSection.loadTransports();
}
}
}
break;
case 'transport_removed':
// Transport wurde entfernt (z.B. abgebrochen oder gelöscht)
if (eventData.branch_id) {
// Nur aktualisieren, wenn der betroffene Branch ausgewählt ist
if (this.selectedBranch && this.selectedBranch.id === eventData.branch_id) {
// Fahrzeuge im Transport-Tab aktualisieren
this.loadVehicles();
// Laufende Transporte im Inventar-Tab aktualisieren
if (this.$refs.saleSection) {
this.$refs.saleSection.loadInventory();
this.$refs.saleSection.loadTransports();
}
// Lager aktualisieren
if (this.$refs.storageSection) {
this.$refs.storageSection.loadStorageData();
}
if (this.$refs.productionSection) {
this.$refs.productionSection.loadStorage();
}
}
}
break;
case 'inventory_updated':
// Inventar wurde aktualisiert (durch Transporte)
if (eventData.branch_id) {
// Nur aktualisieren, wenn der betroffene Branch ausgewählt ist
if (this.selectedBranch && this.selectedBranch.id === eventData.branch_id) {
// Inventar im Inventar-Tab aktualisieren
if (this.$refs.saleSection) {
this.$refs.saleSection.loadInventory();
this.$refs.saleSection.loadTransports();
}
// Lager aktualisieren
if (this.$refs.storageSection) {
this.$refs.storageSection.loadStorageData();
}
if (this.$refs.productionSection) {
this.$refs.productionSection.loadStorage();
}
}
}
break;
default:
break;
}
},
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);
}
},
openBuyVehicleDialog() {
if (!this.selectedBranch) return;
this.$refs.buyVehicleDialog?.open();
},
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();
},
openSendVehicleDialog(vehicleId) {
this.sendVehicleDialog = {
show: true,
vehicleId: vehicleId,
vehicleIds: null,
vehicleTypeId: null,
targetBranchId: null,
success: false,
guardCount: 0,
};
},
openSendAllVehiclesDialog(vehicleTypeId, vehicleList) {
this.sendVehicleDialog = {
show: true,
vehicleId: null,
vehicleIds: vehicleList.map(v => v.id),
vehicleTypeId: vehicleTypeId,
targetBranchId: null,
success: false,
guardCount: 0,
};
},
closeSendVehicleDialog() {
this.sendVehicleDialog = {
show: false,
vehicleId: null,
vehicleIds: null,
vehicleTypeId: null,
targetBranchId: null,
success: false,
guardCount: 0,
};
},
targetBranchOptions() {
return (this.branches || [])
.filter(b => ['store', 'fullstack'].includes(b.branchTypeLabelTr))
.filter(b => b.id !== this.selectedBranch?.id)
.map(b => ({
id: b.id,
label: `${b.cityName} ${b.type}`,
}));
},
async sendVehicles() {
if (!this.sendVehicleDialog.targetBranchId) {
showError(this, this.$t('falukant.branch.transport.selectTargetError'));
return;
}
try {
const payload = {
branchId: this.selectedBranch.id,
targetBranchId: this.sendVehicleDialog.targetBranchId,
productId: null,
quantity: 0,
guardCount: this.sendVehicleDialog.guardCount || 0,
};
if (this.sendVehicleDialog.vehicleIds && this.sendVehicleDialog.vehicleIds.length > 0) {
payload.vehicleIds = this.sendVehicleDialog.vehicleIds;
} else if (this.sendVehicleDialog.vehicleId) {
payload.vehicleIds = [this.sendVehicleDialog.vehicleId];
} else if (this.sendVehicleDialog.vehicleTypeId) {
payload.vehicleTypeId = this.sendVehicleDialog.vehicleTypeId;
} else {
showError(this, this.$t('falukant.branch.transport.noVehiclesSelected'));
return;
}
await apiClient.post('/api/falukant/transports', payload);
await this.loadVehicles();
this.sendVehicleDialog.success = true;
} catch (error) {
console.error('Error sending vehicles:', error);
showApiError(this, error, this.$t('falukant.branch.transport.sendError'));
}
},
calculateRepairCost(vehicle) {
if (!vehicle || !vehicle.type || vehicle.condition >= 100) return 0;
const baseCost = vehicle.type.cost || 0;
return Math.round(baseCost * 0.8 * (100 - vehicle.condition) / 100);
},
openRepairVehicleDialog(vehicle) {
if (!vehicle || vehicle.status !== 'available' || vehicle.condition >= 100) {
return;
}
// Kosten berechnen: Baupreis * 0.8 * (100 - zustand) / 100
const repairCost = this.calculateRepairCost(vehicle);
const buildTimeMinutes = vehicle.type.buildTimeMinutes || 0;
this.repairVehicleDialog = {
show: true,
vehicle: vehicle,
repairCost: repairCost,
buildTimeMinutes: buildTimeMinutes,
};
},
openRepairAllVehiclesDialog() {
const repairableVehicles = this.repairableVehicles;
if (repairableVehicles.length === 0) {
return;
}
this.repairAllVehiclesDialog = {
show: true,
vehicleIds: repairableVehicles.map(v => v.id),
totalCost: this.repairAllCost,
};
},
closeRepairAllVehiclesDialog() {
this.repairAllVehiclesDialog = {
show: false,
vehicleIds: [],
totalCost: null,
};
},
async repairAllVehicles() {
if (!this.repairAllVehiclesDialog.vehicleIds || this.repairAllVehiclesDialog.vehicleIds.length === 0) {
return;
}
try {
await apiClient.post('/api/falukant/vehicles/repair-all', {
vehicleIds: this.repairAllVehiclesDialog.vehicleIds,
});
await this.loadVehicles();
this.closeRepairAllVehiclesDialog();
showSuccess(this, this.$t('falukant.branch.transport.repairAllSuccess'));
this.$refs.statusBar?.fetchStatus();
} catch (error) {
console.error('Error repairing all vehicles:', error);
const errorMessage = error.response?.data?.message || this.$t('falukant.branch.transport.repairAllError');
showError(this, errorMessage);
}
},
closeRepairVehicleDialog() {
this.repairVehicleDialog = {
show: false,
vehicle: null,
repairCost: null,
buildTimeMinutes: null,
};
},
formatMoney(amount) {
if (amount == null) return '';
try {
return this.formatCurrency(amount);
} catch (e) {
return String(amount);
}
},
formatCurrency(amount) {
if (amount == null) return '';
try {
// Use euro currency formatting with locale-sensitive digits
return Number(amount).toLocaleString(undefined, { style: 'currency', currency: 'EUR' });
} catch (e) {
return String(amount);
}
},
formatBuildTime(minutes) {
if (!minutes || minutes === 0) {
return this.$t('falukant.branch.transport.repairBuildTimeInstant');
}
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
const parts = [];
if (hours > 0) {
parts.push(`${hours} ${this.$t('falukant.branch.transport.repairBuildTimeHours')}`);
}
if (mins > 0) {
parts.push(`${mins} ${this.$t('falukant.branch.transport.repairBuildTimeMinutes')}`);
}
return parts.length > 0 ? parts.join(' ') : '0 ' + this.$t('falukant.branch.transport.repairBuildTimeMinutes');
},
async repairVehicle() {
if (!this.repairVehicleDialog.vehicle) {
return;
}
try {
await apiClient.post(`/api/falukant/vehicles/${this.repairVehicleDialog.vehicle.id}/repair`);
await this.loadVehicles();
this.closeRepairVehicleDialog();
showSuccess(this, this.$t('falukant.branch.transport.repairSuccess'));
this.$refs.statusBar?.fetchStatus();
} catch (error) {
console.error('Error repairing vehicle:', error);
const errorMessage = error.response?.data?.message || this.$t('falukant.branch.transport.repairError');
showError(this, errorMessage);
}
},
},
};
</script>
<style scoped lang="scss">
.falukant-branch {
max-width: var(--content-max-width);
margin: 0 auto;
padding-bottom: 24px;
}
.branch-hero {
padding: 24px 26px;
margin-bottom: 16px;
background:
radial-gradient(circle at top right, rgba(248, 162, 43, 0.16), transparent 28%),
linear-gradient(180deg, rgba(255, 250, 243, 0.98) 0%, rgba(247, 238, 224, 0.98) 100%);
}
.branch-kicker {
display: inline-block;
margin-bottom: 10px;
padding: 4px 10px;
border-radius: 999px;
background: rgba(248, 162, 43, 0.14);
color: #8a5411;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.branch-hero p {
margin: 0;
color: var(--color-text-secondary);
}
.branch-debt-warning {
margin-bottom: 16px;
padding: 16px 18px;
border: 1px solid rgba(180, 120, 40, 0.32);
background: linear-gradient(180deg, rgba(255, 244, 223, 0.95), rgba(255, 250, 239, 0.98));
}
.branch-debt-warning.is-prison {
border-color: rgba(146, 57, 40, 0.4);
background: linear-gradient(180deg, rgba(255, 232, 225, 0.96), rgba(255, 245, 241, 0.98));
}
.branch-debt-warning p {
margin: 6px 0 0;
color: var(--color-text-secondary);
}
.branch-hero__meta {
margin-top: 12px;
}
.branch-hero__badge {
display: inline-flex;
align-items: center;
padding: 6px 10px;
border: 1px solid rgba(138, 84, 17, 0.16);
border-radius: 999px;
background: rgba(255, 255, 255, 0.7);
color: #7a4b12;
font-size: 0.9rem;
font-weight: 600;
}
.branch-certificate {
margin-bottom: 16px;
padding: 18px;
}
.branch-certificate__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 14px;
}
.branch-certificate__header p {
margin: 6px 0 0;
color: var(--color-text-secondary);
}
.branch-certificate__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.branch-certificate__block {
padding: 14px 16px;
border-radius: var(--radius-lg);
background: rgba(138, 84, 17, 0.06);
}
.branch-certificate__block h4 {
margin: 0 0 8px;
}
.branch-certificate__block p {
margin: 0;
color: var(--color-text-secondary);
}
.branch-tab-content {
margin-top: 16px;
padding: 18px;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: rgba(255, 252, 247, 0.86);
box-shadow: var(--shadow-soft);
}
.branch-tab-pane {
min-height: 0;
}
.send-all-vehicles {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid #ddd;
}
.send-all-buttons {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.send-all-buttons button {
padding: 0.5rem 1rem;
background-color: #F9A22C;
color: #000000;
border: 1px solid #F9A22C;
border-radius: 4px;
cursor: pointer;
}
.send-all-buttons button:hover {
background-color: #fdf1db;
color: #7E471B;
border: 1px solid #7E471B;
}
.no-action {
color: #999;
font-style: italic;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: rgba(255,255,255,0.98);
padding: 2rem;
border-radius: var(--radius-lg);
min-width: 400px;
max-width: 600px;
box-shadow: var(--shadow-medium);
}
.send-vehicle-form {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1rem;
}
.send-vehicle-form label {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.send-vehicle-form select {
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
}
.modal-buttons {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1rem;
}
.modal-buttons button {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
}
.modal-buttons button:first-child {
background-color: #007bff;
color: white;
}
.modal-buttons button:first-child:hover {
background-color: #0056b3;
}
.modal-buttons button:first-child:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.modal-buttons button:last-child {
background-color: #6c757d;
color: white;
}
.modal-buttons button:last-child:hover {
background-color: #5a6268;
}
.vehicle-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.repair-button {
background-color: #28a745;
color: white;
}
.repair-button:hover {
background-color: #218838;
}
.repair-vehicle-form {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1rem;
}
.repair-info {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.repair-info p {
margin: 0;
}
.repair-confirm-button {
background-color: #28a745;
color: white;
}
.repair-confirm-button:hover {
background-color: #218838;
}
.send-vehicle-success {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1rem;
text-align: center;
}
.send-vehicle-success p {
margin: 0;
font-size: 1.1em;
color: #28a745;
}
.vehicle-action-buttons {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.repair-all-button {
background-color: #28a745;
color: white;
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
}
.repair-all-button:hover {
background-color: #218838;
}
.repair-all-form {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1rem;
}
.repair-all-discount {
color: #28a745;
font-weight: bold;
}
</style>