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

684 lines
24 KiB
Vue
Executable File

<template>
<div class="house-view">
<StatusBar />
<h2>{{ $t('falukant.house.title') }}</h2>
<section
v-if="debtorsPrison.active"
class="house-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.house.debtorsPrison.houseRisk')
: $t('falukant.house.debtorsPrison.houseWarning') }}
</p>
</section>
<div class="existing-house">
<div :style="houseType ? houseStyle(houseType.position, 341) : {}" class="house"></div>
<div class="status-panel surface-card">
<h3>{{ $t('falukant.house.statusreport') }}</h3>
<div class="status-cards">
<article v-for="(value, key) in status" :key="key" class="status-card">
<div>
<span class="status-card__label">{{ $t(`falukant.house.status.${key}`) }}</span>
<strong>{{ conditionLabel(value) }}</strong>
</div>
<button v-if="value < 100" @click="renovate(key)">
{{ $t('falukant.house.renovate') }} ({{ getRenovationCost(key, value) }})
</button>
</article>
<article class="status-card status-card--summary">
<div>
<span class="status-card__label">{{ $t('falukant.house.worth') }}</span>
<strong>{{ getWorth() }} {{ currency }}</strong>
</div>
<div class="status-card__actions">
<button @click="renovateAll" :disabled="allRenovated">
{{ $t('falukant.house.renovateAll') }} ({{ getAllRenovationCost() }})
</button>
<button class="button-secondary" @click="sellHouse">
{{ $t('falukant.house.sell') }}
</button>
</div>
</article>
</div>
</div>
</div>
<section v-if="userHouse" class="servants-panel surface-card">
<div class="servants-panel__header">
<div>
<h3>{{ $t('falukant.house.servants.title') }}</h3>
<p>{{ $t('falukant.house.servants.description') }}</p>
</div>
<div class="servants-panel__actions">
<button @click="hireServant">
{{ $t('falukant.house.servants.actions.hire') }}
</button>
<button class="button-secondary" :disabled="(userHouse.servantCount || 0) <= 0" @click="dismissServant">
{{ $t('falukant.house.servants.actions.dismiss') }}
</button>
<button class="button-secondary" @click="tidyHousehold">
{{ $t('falukant.house.servants.actions.tidy') }}
</button>
</div>
</div>
<div class="servants-grid">
<article class="servant-card">
<span class="servant-card__label">{{ $t('falukant.house.servants.count') }}</span>
<strong>{{ userHouse.servantCount || 0 }}</strong>
</article>
<article class="servant-card">
<span class="servant-card__label">{{ $t('falukant.house.servants.expectedRange') }}</span>
<strong>{{ servantSummary.expectedMin }} - {{ servantSummary.expectedMax }}</strong>
</article>
<article class="servant-card">
<span class="servant-card__label">{{ $t('falukant.house.servants.monthlyCost') }}</span>
<strong>{{ formatPrice(servantSummary.monthlyCost || 0) }} {{ currency }}</strong>
</article>
<article class="servant-card">
<span class="servant-card__label">{{ $t('falukant.house.servants.quality') }}</span>
<strong>{{ userHouse.servantQuality || 0 }}</strong>
</article>
<article class="servant-card">
<span class="servant-card__label">{{ $t('falukant.house.servants.householdOrder') }}</span>
<strong>{{ userHouse.householdOrder || 0 }}</strong>
</article>
<article class="servant-card">
<span class="servant-card__label">{{ $t('falukant.family.householdTension.score') }}</span>
<strong>{{ userHouse.householdTensionScore ?? 0 }}</strong>
</article>
<article class="servant-card">
<span class="servant-card__label">{{ $t('falukant.house.servants.staffingState.label') }}</span>
<strong>{{ $t(`falukant.house.servants.staffingState.${servantSummary.staffingState || 'fitting'}`) }}</strong>
</article>
</div>
<div class="servants-settings">
<label class="servants-settings__label">
{{ $t('falukant.house.servants.payLevel') }}
<select v-model="servantPayLevel" @change="updateServantPayLevel" class="servants-settings__select">
<option v-for="option in servantPayOptions" :key="option" :value="option">
{{ $t(`falukant.house.servants.payLevels.${option}`) }}
</option>
</select>
</label>
<div class="servants-settings__state">
{{ $t('falukant.house.servants.orderState.label') }}:
<strong>{{ $t(`falukant.house.servants.orderState.${servantSummary.orderState || 'stable'}`) }}</strong>
</div>
</div>
<div v-if="Array.isArray(userHouse.householdTensionReasonsJson) && userHouse.householdTensionReasonsJson.length > 0" class="servants-reasons">
<span class="servants-reasons__label">{{ $t('falukant.family.householdTension.reasonsLabel') }}</span>
<div class="servants-reasons__list">
<span v-for="reason in userHouse.householdTensionReasonsJson" :key="reason" class="servants-reasons__badge">
{{ $t('falukant.family.householdTension.reasons.' + reason) }}
</span>
</div>
</div>
</section>
<div class="buyable-houses">
<h3>{{ $t('falukant.house.buyablehouses') }}</h3>
<div class="houses-list">
<div v-for="house in buyableHouses" :key="house.id" class="house-item">
<div :style="house.houseType ? houseStyle(house.houseType.position, 114) : {}"
class="house-preview"></div>
<div class="house-info">
<h4>{{ $t(`falukant.house.type.${house.houseType.labelTr}`) }}</h4>
<div class="buyable-house-stats">
<div v-for="(val, prop) in house" :key="prop"
v-if="['roofCondition', 'wallCondition', 'floorCondition', 'windowCondition'].includes(prop)"
class="buyable-house-stat">
<span>{{ $t(`falukant.house.status.${prop}`) }}</span>
<strong>{{ conditionLabel(val) }}</strong>
</div>
</div>
<div class="buyable-house-price">
{{ $t('falukant.house.price') }}: {{ buyCost(house) }}
</div>
<button @click="buyHouse(house.id)" :disabled="debtorsPrison.inDebtorsPrison">
{{ $t('falukant.house.buy') }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import StatusBar from '@/components/falukant/StatusBar.vue';
import apiClient from '@/utils/axios.js';
import { mapState } from 'vuex';
import { showError, showSuccess, confirmAction } from '@/utils/feedback.js';
export default {
name: 'HouseView',
components: { StatusBar },
data() {
return {
userHouse: null,
houseType: {},
status: {},
servantSummary: {
expectedMin: 0,
expectedMax: 0,
monthlyCost: 0,
staffingState: 'fitting',
orderState: 'stable'
},
servantPayLevel: 'normal',
servantPayOptions: ['low', 'normal', 'high'],
buyableHouses: [],
currency: '€',
debtorsPrison: {
active: false,
inDebtorsPrison: false
}
};
},
computed: {
...mapState(['socket', 'daemonSocket', 'user']),
allRenovated() {
return Object.values(this.status).every(v => v >= 100);
}
},
methods: {
async loadData() {
try {
const userRes = await apiClient.get('/api/falukant/houses');
this.userHouse = userRes.data;
this.houseType = this.userHouse.houseType;
this.debtorsPrison = this.userHouse.debtorsPrison || {
active: false,
inDebtorsPrison: false
};
const { roofCondition, wallCondition, floorCondition, windowCondition } = this.userHouse;
this.status = { roofCondition, wallCondition, floorCondition, windowCondition };
this.servantSummary = this.userHouse.servantSummary || this.servantSummary;
this.servantPayLevel = this.userHouse.servantPayLevel || 'normal';
const buyRes = await apiClient.get('/api/falukant/houses/buyable');
this.buyableHouses = buyRes.data;
} catch (err) {
console.error('Error loading house data', err);
}
},
conditionLabel(value) {
const v = Number(value) || 0;
if (v >= 95) return this.$t('falukant.conditionBand.excellent');
if (v >= 72) return this.$t('falukant.conditionBand.veryGood');
if (v >= 54) return this.$t('falukant.conditionBand.good');
if (v >= 39) return this.$t('falukant.conditionBand.moderate');
if (v >= 22) return this.$t('falukant.conditionBand.bad');
if (v >= 6) return this.$t('falukant.conditionBand.veryBad');
if (v >= 1) return this.$t('falukant.conditionBand.catastrophic');
return this.$t('falukant.conditionBand.unknown');
},
houseStyle(position, picSize) {
const columns = 3;
const size = picSize;
const index = position - 1;
const x = (index % columns) * size;
const y = Math.floor(index / columns) * size;
return {
backgroundImage: 'url("/images/falukant/houses.png")',
backgroundPosition: `-${x}px -${y}px`,
backgroundSize: `${columns * size}px auto`
};
},
formatPrice(value) {
return new Intl.NumberFormat('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value);
},
getRenovationCost(key, value) {
if (!this.userHouse || !this.userHouse.houseType) return this.formatPrice(0);
const base = this.userHouse.houseType.cost || 0;
const weights = { roofCondition: 0.25, wallCondition: 0.25, floorCondition: 0.25, windowCondition: 0.25 };
const weight = weights[key] || 0;
const missing = 100 - value;
const cost = (missing / 100) * base * weight;
return this.formatPrice(cost);
},
getAllRenovationCost() {
const total = Object.keys(this.status).reduce((sum, k) => {
const raw = parseFloat(this.getRenovationCost(k, this.status[k]).replace(/\./g, '').replace(',', '.'));
return sum + (isNaN(raw) ? 0 : raw);
}, 0);
return this.formatPrice(total * 0.8);
},
getWorth() {
const vals = Object.values(this.status);
if (!vals.length || !this.houseType) return this.formatPrice(0);
const avg = vals.reduce((s, v) => s + v, 0) / vals.length;
const price = this.houseType.cost || 0;
return this.formatPrice(price * avg / 100 * 0.8);
},
buyCost(house) {
if (!house || !house.houseType) return this.formatPrice(0);
const avg = (house.roofCondition + house.wallCondition + house.floorCondition + house.windowCondition) / 4;
return this.formatPrice(house.houseType.cost * avg / 100);
},
async renovate(key) {
try {
await apiClient.post('/api/falukant/houses/renovate', { element: key });
await this.loadData();
} catch (err) {
console.error('Error renovating', err);
}
},
async renovateAll() {
try {
await apiClient.post('/api/falukant/houses/renovate-all');
await this.loadData();
} catch (err) {
console.error('Error renovating all', err);
}
},
async sellHouse() {
const confirmed = await confirmAction(this, {
title: this.$t('falukant.house.sell'),
text: this.$t('falukant.house.sellConfirm')
});
if (!confirmed) return;
try {
await apiClient.post('/api/falukant/houses/sell');
await this.loadData();
showSuccess(this, this.$t('falukant.house.sellSuccess'));
} catch (err) {
console.error('Error selling house', err);
showError(this, this.$t('falukant.house.sellError'));
}
},
async buyHouse(id) {
try {
await apiClient.post('/api/falukant/houses', { houseId: id });
await this.loadData();
showSuccess(this, this.$t('falukant.house.buySuccess'));
} catch (err) {
console.error('Error buying house', err);
showError(this, this.$t('falukant.house.buyError'));
}
},
async hireServant() {
try {
await apiClient.post('/api/falukant/houses/servants/hire', { amount: 1 });
await this.loadData();
showSuccess(this, this.$t('falukant.house.servants.actions.hireSuccess'));
} catch (err) {
console.error('Error hiring servant', err);
showError(this, this.$t('falukant.house.servants.actions.hireError'));
}
},
async dismissServant() {
try {
await apiClient.post('/api/falukant/houses/servants/dismiss', { amount: 1 });
await this.loadData();
showSuccess(this, this.$t('falukant.house.servants.actions.dismissSuccess'));
} catch (err) {
console.error('Error dismissing servant', err);
showError(this, this.$t('falukant.house.servants.actions.dismissError'));
}
},
async updateServantPayLevel() {
try {
await apiClient.post('/api/falukant/houses/servants/pay-level', {
payLevel: this.servantPayLevel
});
await this.loadData();
showSuccess(this, this.$t('falukant.house.servants.actions.payLevelSuccess'));
} catch (err) {
console.error('Error updating servant pay level', err);
showError(this, this.$t('falukant.house.servants.actions.payLevelError'));
}
},
async tidyHousehold() {
try {
await apiClient.post('/api/falukant/houses/order');
await this.loadData();
showSuccess(this, this.$t('falukant.house.servants.actions.tidySuccess'));
} catch (err) {
console.error('Error tidying household', err);
showError(this, this.$t('falukant.house.servants.actions.tidyError'));
}
},
handleDaemonMessage(evt) {
try {
const msg = JSON.parse(evt.data);
if (!this.matchesCurrentUser(msg)) return;
if (['houseupdated', 'falukantUpdateStatus', 'falukantUpdateDebt', 'falukantHouseUpdate'].includes(msg.event)) this.loadData();
} catch { }
},
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));
},
setupSocketEvents() {
if (this.socket) {
this.socket.on('falukantHouseUpdate', (data) => {
this.handleEvent({ event: 'falukantHouseUpdate', ...data });
});
this.socket.on('falukantUpdateDebt', (data) => {
this.handleEvent({ event: 'falukantUpdateDebt', ...data });
});
this.socket.on('falukantUpdateStatus', (data) => {
this.handleEvent({ event: 'falukantUpdateStatus', ...data });
});
} else {
setTimeout(() => this.setupSocketEvents(), 1000);
}
},
handleEvent(eventData) {
switch (eventData.event) {
case 'falukantUpdateStatus':
case 'falukantUpdateDebt':
case 'falukantHouseUpdate':
this.loadData();
break;
}
}
},
async mounted() {
await this.loadData();
this.setupSocketEvents();
if (this.daemonSocket) {
this.daemonSocket.addEventListener('message', this.handleDaemonMessage);
}
},
beforeUnmount() {
if (this.daemonSocket) {
this.daemonSocket.removeEventListener('message', this.handleDaemonMessage);
}
if (this.socket) {
this.socket.off('falukantHouseUpdate', this.loadData);
this.socket.off('falukantUpdateDebt', this.loadData);
this.socket.off('falukantUpdateStatus', this.loadData);
}
}
};
</script>
<style scoped>
.house-view {
display: flex;
flex-direction: column;
gap: 20px;
/* AppContent gibt dem letzten Kind flex:1 + min-height:0 — sonst schrumpfen
Spalten-Kinder und überlagern sich (z. B. „Dienerschaft“ unter „Kaufe ein Haus“). */
flex: 0 0 auto;
min-height: min-content;
width: 100%;
}
.existing-house,
.servants-panel,
.buyable-houses {
flex-shrink: 0;
}
h2 {
padding-top: 20px;
margin: 0 0 10px;
}
.house-debt-warning {
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));
}
.house-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));
}
.house-debt-warning p {
margin: 6px 0 0;
color: var(--color-text-secondary);
}
.existing-house {
display: flex;
gap: 20px;
}
.house {
width: 341px;
height: 341px;
background-repeat: no-repeat;
image-rendering: crisp-edges;
border: 1px solid var(--color-border);
border-radius: 4px;
}
.status-panel {
flex: 1;
padding: 18px;
}
.servants-panel {
padding: 18px;
}
.servants-panel__header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
margin-bottom: 16px;
}
.servants-panel__header h3 {
margin: 0 0 6px;
}
.servants-panel__header p {
margin: 0;
color: var(--color-text-secondary);
}
.servants-panel__actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.servants-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.servant-card {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 16px;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.68);
}
.servant-card__label {
color: var(--color-text-secondary);
font-size: 0.88rem;
}
.servants-settings {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.servants-settings__label {
display: flex;
flex-direction: column;
gap: 6px;
font-weight: 600;
}
.servants-settings__select {
min-width: 200px;
}
.servants-settings__state {
color: var(--color-text-secondary);
}
.servants-reasons {
display: grid;
gap: 8px;
margin-top: 16px;
}
.servants-reasons__label {
color: var(--color-text-secondary);
font-size: 0.88rem;
}
.servants-reasons__list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.servants-reasons__badge {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 999px;
background: rgba(188, 84, 61, 0.12);
color: #9a3c26;
font-size: 0.8rem;
font-weight: 700;
}
.buyable-houses {
display: flex;
flex-direction: column;
gap: 10px;
}
.houses-list {
display: flex;
flex-direction: column;
/* vertical list */
gap: 20px;
max-height: 400px;
overflow-y: auto;
/* vertical scroll if needed */
}
.house-item {
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
padding: 18px;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.68);
}
.house-preview {
width: 100px;
height: 100px;
background-repeat: no-repeat;
image-rendering: crisp-edges;
border: 1px solid var(--color-border);
border-radius: 4px;
background-size: contain;
/* scale image to container */
background-position: center;
/* center sprite */
}
.house-info {
width: 100%;
display: flex;
flex-direction: column;
gap: 12px;
}
.status-cards,
.buyable-house-stats {
display: grid;
gap: 12px;
}
.status-card,
.buyable-house-stat {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.68);
}
.status-card__label,
.buyable-house-stat span {
display: block;
margin-bottom: 4px;
color: var(--color-text-secondary);
font-size: 0.88rem;
}
.status-card--summary {
align-items: flex-start;
flex-direction: column;
}
.status-card__actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.buyable-house-price {
font-weight: 700;
}
button {
padding: 6px 12px;
cursor: pointer;
}
@media (max-width: 960px) {
.existing-house {
flex-direction: column;
}
.servants-panel__header {
flex-direction: column;
}
.house {
width: min(341px, 100%);
margin: 0 auto;
}
.status-card,
.buyable-house-stat {
flex-direction: column;
align-items: flex-start;
}
}
</style>