330 lines
11 KiB
Vue
330 lines
11 KiB
Vue
<template>
|
|
<div class="bank-view">
|
|
<StatusBar />
|
|
<div class="bank-content">
|
|
|
|
<h2>{{ $t('falukant.bank.title') }}</h2>
|
|
<SimpleTabs v-model="activeTab" :tabs="tabs" />
|
|
|
|
<!-- OVERVIEW -->
|
|
<div v-if="activeTab === 'account'">
|
|
<div v-if="debtorsPrison.active" class="debt-status" :class="{ 'is-prison': debtorsPrison.inDebtorsPrison }">
|
|
<h3>{{ debtStatusTitle }}</h3>
|
|
<p>{{ debtStatusDescription }}</p>
|
|
<div class="debt-status__meta">
|
|
<span>{{ $t('falukant.bank.debtorsPrison.daysOverdue') }}: <strong>{{ debtorsPrison.daysOverdue }}</strong></span>
|
|
<span>{{ $t('falukant.bank.debtorsPrison.creditworthiness') }}: <strong>{{ debtorsPrison.creditworthiness }}</strong></span>
|
|
<span v-if="debtorsPrison.nextForcedAction">
|
|
{{ $t('falukant.bank.debtorsPrison.nextForcedAction') }}:
|
|
<strong>{{ $t(`falukant.bank.debtorsPrison.actions.${debtorsPrison.nextForcedAction}`) }}</strong>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="account-section">
|
|
<table>
|
|
<tr>
|
|
<td>{{ $t('falukant.bank.account.balance') }}</td>
|
|
<td>{{ formatCost(bankOverview.money) }}</td>
|
|
</tr>
|
|
<tr>
|
|
<td>{{ $t('falukant.bank.account.totalDebt') }}</td>
|
|
<td>{{ formatCost(bankOverview.totalDebt) }}</td>
|
|
</tr>
|
|
<tr>
|
|
<td>{{ $t('falukant.bank.account.maxCredit') }}</td>
|
|
<td>{{ formatCost(bankOverview.maxCredit) }}</td>
|
|
</tr>
|
|
<tr>
|
|
<td>{{ $t('falukant.bank.account.availableCredit') }}</td>
|
|
<td>{{ formatCost(bankOverview.availableCredit) }}</td>
|
|
</tr>
|
|
<tr>
|
|
<td>{{ $t('falukant.bank.debtorsPrison.creditworthiness') }}</td>
|
|
<td>{{ bankOverview.creditworthiness }}</td>
|
|
</tr>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ACTIVE CREDITS -->
|
|
<div v-else-if="activeTab === 'credits'">
|
|
<div class="credits-section">
|
|
<div v-if="bankOverview.activeCredits?.length">
|
|
<table class="credits-table">
|
|
<thead>
|
|
<tr>
|
|
<th>{{ $t('falukant.bank.credits.amount') }}</th>
|
|
<th>{{ $t('falukant.bank.credits.remaining') }}</th>
|
|
<th>{{ $t('falukant.bank.credits.interestRate') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="credit in bankOverview.activeCredits" :key="credit.id">
|
|
<td>{{ formatCost(credit.amount) }}</td>
|
|
<td>{{ formatCost(credit.remainingAmount) }}</td>
|
|
<td>{{ credit.interestRate }}%</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div v-else>
|
|
<p>{{ $t('falukant.bank.credits.none') }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- PAYOFF INLINE -->
|
|
<div v-else-if="activeTab === 'payoff'">
|
|
<div class="payoff-section">
|
|
<label>
|
|
{{ $t('falukant.bank.credits.payoff.height') }}:
|
|
<input
|
|
type="number"
|
|
v-model="selectedCredit"
|
|
:min="0"
|
|
:max="bankOverview.availableCredit"
|
|
value="0"
|
|
/>
|
|
</label>
|
|
<div v-if="selectedCredit">
|
|
<p>{{ $t('falukant.bank.credits.payoff.remaining') }}: {{ formatCost(bankOverview.availableCredit - selectedCredit) }}</p>
|
|
<p>{{ $t('falukant.bank.credits.payoff.fee') }}: {{ formatCost(bankOverview.fee) }}</p>
|
|
<p>{{ $t('falukant.bank.credits.payoff.feeHeight') }}: {{ formatCost(feeRate()) }}</p>
|
|
<p>
|
|
<strong>{{ $t('falukant.bank.credits.payoff.total') }}: {{ formatCost(creditCost()) }}</strong>
|
|
</p>
|
|
<button @click="confirmPayoff" class="button" :disabled="!selectedCredit || isCreditBlocked">
|
|
{{ $t('falukant.bank.credits.payoff.confirm') }}
|
|
</button>
|
|
<p v-if="isCreditBlocked" class="payoff-hint payoff-hint--error">
|
|
{{ $t('falukant.bank.debtorsPrison.creditBlocked') }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
import StatusBar from '@/components/falukant/StatusBar.vue';
|
|
import SimpleTabs from '@/components/SimpleTabs.vue';
|
|
import apiClient from '@/utils/axios.js';
|
|
import { mapState } from 'vuex';
|
|
import { showError } from '@/utils/feedback.js';
|
|
|
|
export default {
|
|
name: 'BankView',
|
|
components: { StatusBar, SimpleTabs },
|
|
data() {
|
|
return {
|
|
activeTab: 'account',
|
|
tabs: [
|
|
{ value: 'account', label: 'falukant.bank.account.title' },
|
|
{ value: 'credits', label: 'falukant.bank.credits.title' },
|
|
{ value: 'payoff', label: 'falukant.bank.credits.payoff.title' }
|
|
],
|
|
bankOverview: {
|
|
money: 0,
|
|
totalDebt: 0,
|
|
maxCredit: 0,
|
|
availableCredit: 0,
|
|
activeCredits: []
|
|
},
|
|
selectedCreditId: null,
|
|
selectedCredit: null,
|
|
earlyPayoffFee: 0
|
|
};
|
|
},
|
|
computed: {
|
|
...mapState(['socket', 'daemonSocket', 'user']),
|
|
debtorsPrison() {
|
|
return this.bankOverview.debtorsPrison || {
|
|
active: false,
|
|
inDebtorsPrison: false,
|
|
daysOverdue: 0,
|
|
creditworthiness: 100,
|
|
nextForcedAction: null
|
|
};
|
|
},
|
|
isCreditBlocked() {
|
|
return this.debtorsPrison.inDebtorsPrison;
|
|
},
|
|
debtStatusTitle() {
|
|
return this.debtorsPrison.inDebtorsPrison
|
|
? this.$t('falukant.bank.debtorsPrison.titlePrison')
|
|
: this.$t('falukant.bank.debtorsPrison.titleWarning');
|
|
},
|
|
debtStatusDescription() {
|
|
return this.debtorsPrison.inDebtorsPrison
|
|
? this.$t('falukant.bank.debtorsPrison.descriptionPrison')
|
|
: this.$t('falukant.bank.debtorsPrison.descriptionWarning');
|
|
}
|
|
},
|
|
watch: {
|
|
socket(newVal, oldVal) {
|
|
if (oldVal) this.teardownSocketEvents();
|
|
if (newVal) this.setupSocketEvents();
|
|
},
|
|
daemonSocket(newVal, oldVal) {
|
|
if (oldVal) this.teardownSocketEvents();
|
|
if (newVal) this.setupSocketEvents();
|
|
}
|
|
},
|
|
async mounted() {
|
|
await this.loadBankOverview();
|
|
this.setupSocketEvents();
|
|
},
|
|
beforeUnmount() {
|
|
if (this._pendingRefresh) {
|
|
clearTimeout(this._pendingRefresh);
|
|
this._pendingRefresh = null;
|
|
}
|
|
this.teardownSocketEvents();
|
|
},
|
|
methods: {
|
|
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() {
|
|
this.teardownSocketEvents();
|
|
if (this.socket) {
|
|
this._statusSocketHandler = (data) => this.handleEvent({ event: 'falukantUpdateStatus', ...data });
|
|
this._familySocketHandler = (data) => this.handleEvent({ event: 'falukantUpdateFamily', ...data });
|
|
this._debtSocketHandler = (data) => this.handleEvent({ event: 'falukantUpdateDebt', ...data });
|
|
this.socket.on('falukantUpdateStatus', this._statusSocketHandler);
|
|
this.socket.on('falukantUpdateFamily', this._familySocketHandler);
|
|
this.socket.on('falukantUpdateDebt', this._debtSocketHandler);
|
|
}
|
|
if (this.daemonSocket) {
|
|
this._daemonHandler = (event) => this.handleDaemonMessage(event);
|
|
this.daemonSocket.addEventListener('message', this._daemonHandler);
|
|
}
|
|
},
|
|
teardownSocketEvents() {
|
|
if (this.socket) {
|
|
if (this._statusSocketHandler) this.socket.off('falukantUpdateStatus', this._statusSocketHandler);
|
|
if (this._familySocketHandler) this.socket.off('falukantUpdateFamily', this._familySocketHandler);
|
|
if (this._debtSocketHandler) this.socket.off('falukantUpdateDebt', this._debtSocketHandler);
|
|
}
|
|
if (this.daemonSocket && this._daemonHandler) {
|
|
this.daemonSocket.removeEventListener('message', this._daemonHandler);
|
|
this._daemonHandler = null;
|
|
}
|
|
},
|
|
handleEvent(eventData) {
|
|
if (!this.matchesCurrentUser(eventData)) {
|
|
return;
|
|
}
|
|
switch (eventData.event) {
|
|
case 'falukantUpdateStatus':
|
|
this.queueBankRefresh();
|
|
break;
|
|
case 'falukantUpdateDebt':
|
|
this.queueBankRefresh();
|
|
break;
|
|
case 'falukantUpdateFamily':
|
|
if (['monthly', 'lover_installment'].includes(eventData.reason)) {
|
|
this.queueBankRefresh();
|
|
}
|
|
break;
|
|
}
|
|
},
|
|
queueBankRefresh() {
|
|
if (this._pendingRefresh) {
|
|
clearTimeout(this._pendingRefresh);
|
|
}
|
|
this._pendingRefresh = setTimeout(() => {
|
|
this._pendingRefresh = null;
|
|
this.loadBankOverview();
|
|
}, 120);
|
|
},
|
|
async loadBankOverview() {
|
|
try {
|
|
const { data } = await apiClient.get('/api/falukant/bank/overview');
|
|
this.bankOverview = data;
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
},
|
|
async confirmPayoff() {
|
|
if (this.isCreditBlocked) return;
|
|
try {
|
|
await apiClient.post('/api/falukant/bank/credits', {
|
|
height: this.selectedCredit
|
|
});
|
|
await this.loadBankOverview();
|
|
this.selectedCredit = null;
|
|
this.activeTab = 'credits';
|
|
} catch (err) {
|
|
showError(err.response?.data?.error || this.$t('falukant.bank.debtorsPrison.creditError'));
|
|
}
|
|
},
|
|
handleDaemonMessage(msg) {
|
|
try {
|
|
const data = JSON.parse(msg.data);
|
|
if (['falukantUpdateStatus', 'falukantUpdateDebt'].includes(data.event)) {
|
|
this.handleEvent(data);
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
},
|
|
feeRate() {
|
|
return this.bankOverview.fee * this.selectedCredit / 100 + this.selectedCredit / 10;
|
|
},
|
|
creditCost() {
|
|
return this.selectedCredit + (this.bankOverview.fee * 10 * this.selectedCredit / 100);
|
|
},
|
|
formatCost(val) {
|
|
return new Intl.NumberFormat(navigator.language, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(val);
|
|
}
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
h2 { padding-top: 20px; }
|
|
|
|
.debt-status {
|
|
margin-bottom: 1rem;
|
|
padding: 1rem 1.1rem;
|
|
border-radius: var(--radius-md);
|
|
border: 1px solid rgba(180, 120, 40, 0.35);
|
|
background: linear-gradient(180deg, rgba(255, 244, 223, 0.95), rgba(255, 249, 238, 0.98));
|
|
}
|
|
|
|
.debt-status.is-prison {
|
|
border-color: rgba(146, 57, 40, 0.45);
|
|
background: linear-gradient(180deg, rgba(255, 232, 225, 0.96), rgba(255, 245, 241, 0.98));
|
|
}
|
|
|
|
.debt-status h3 {
|
|
margin-bottom: 0.35rem;
|
|
}
|
|
|
|
.debt-status__meta {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 1rem;
|
|
margin-top: 0.75rem;
|
|
font-size: 0.95rem;
|
|
}
|
|
|
|
.payoff-hint {
|
|
margin-top: 0.75rem;
|
|
}
|
|
|
|
.payoff-hint--error {
|
|
color: #8b2f23;
|
|
font-weight: 600;
|
|
}
|
|
</style>
|