Files
jk8_admin/web/src/views/backend/dashboard.vue
2026-06-11 15:32:22 +08:00

1495 lines
52 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="default-main bookkeeping-dashboard">
<section class="dashboard-grid">
<div class="dashboard-panel bank-panel">
<div class="panel-title">{{ t('dashboard.Available Bank Balance') }}</div>
<div class="table-scroll">
<table class="bank-table">
<thead>
<tr>
<th>{{ t('dashboard.Bank Name') }}</th>
<th>{{ t('dashboard.Current Balance') }}</th>
<th>{{ t('dashboard.Transaction Breakdown') }}</th>
<th>{{ t('dashboard.Safe Alert') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="bank in visibleBanks"
:key="bank.id"
:class="[bank.rowClass, { 'bank-colored': bank.labelColor }]"
:style="bankRowStyle(bank)"
>
<td>
<strong>{{ bank.name }}</strong>
<small>{{ bank.account }}</small>
</td>
<td class="balance">AUD {{ money(bank.balance) }}</td>
<td>
<div class="bank-operate">
<input :id="calculatorToggleId(bank)" class="calculator-toggle" type="checkbox" />
<div class="bank-operate-main">
<label
class="calculator-button"
:for="calculatorToggleId(bank)"
:aria-label="t('dashboard.Fast Calculation')"
:title="t('dashboard.Fast Calculation')"
>
<Icon name="fa fa-calculator" size="13" color="currentColor" />
</label>
<el-button size="small" :icon="Switch" @click="openTransfer(bank)">
{{ t('dashboard.Transfer') }}
</el-button>
<div class="breakdown">
<div class="breakdown-row breakdown-in">
<span class="breakdown-label"></span>
<span class="breakdown-count">({{ bank.depositCount }})</span>
<strong>{{ money(bank.deposit) }}</strong>
</div>
<div class="breakdown-row breakdown-out">
<span class="breakdown-label"></span>
<span class="breakdown-count">({{ bank.withdrawCount }})</span>
<strong>{{ money(bank.withdraw) }}</strong>
</div>
</div>
</div>
<div class="bank-calculator">
<div class="calculator-formula">
<el-input
v-model="calculatorFormulas[calculatorKey(bank)]"
class="calculator-input"
size="small"
:placeholder="t('dashboard.Calculation placeholder')"
clearable
/>
<span class="calculator-equals">=</span>
<strong class="calculator-result" :class="{ 'is-invalid': isCalculatorInvalid(bank) }">
{{ calculatorResultText(bank) }}
</strong>
</div>
<div class="calculator-hint">
{{ t('dashboard.Fast Calculation Support') }}
<span>+ × ÷</span>
</div>
</div>
</div>
</td>
<td>
<el-tag size="small" effect="plain" :type="bank.alertCode !== '0' ? 'danger' : 'info'">
{{ safeAlertText(bank.alertCode) }}
</el-tag>
</td>
</tr>
<tr v-if="!visibleBanks.length">
<td class="empty-banks" colspan="4">{{ t('dashboard.No bank data') }}</td>
</tr>
</tbody>
</table>
</div>
<button v-if="banks.length > 4" class="more-info" type="button" @click="showAllBanks = !showAllBanks">
{{ showAllBanks ? t('dashboard.Show less') : t('dashboard.More info') }}
<Icon :name="showAllBanks ? 'fa fa-caret-up' : 'fa fa-caret-down'" />
</button>
</div>
<aside class="summary-side">
<div class="dashboard-panel summary-panel">
<div class="panel-title">{{ t('dashboard.Customer Summary') }}</div>
<dl>
<template v-for="item in summary" :key="item.label">
<dt>{{ item.label }}:</dt>
<dd>{{ item.value }}</dd>
</template>
</dl>
</div>
<el-button class="create-button" type="success" @click="openCreate">{{ t('dashboard.Create New Transaction') }}</el-button>
</aside>
</section>
<el-alert class="webhook-alert" type="warning" :closable="true" show-icon>
<template #title>
<strong>{{ t('dashboard.Webhook enabled') }}</strong>
</template>
<p>{{ t('dashboard.Webhook hint') }}</p>
</el-alert>
<section class="transaction-section">
<div class="filter-row">
<div class="date-filter">
<label>{{ t('dashboard.Start Date') }}:</label>
<el-date-picker v-model="filters.startDate" type="date" value-format="YYYY-MM-DD" />
<label>{{ t('dashboard.End Date') }}:</label>
<el-date-picker v-model="filters.endDate" type="date" value-format="YYYY-MM-DD" />
<el-button @click="search">{{ t('Search') }}</el-button>
<el-button @click="setToday">{{ t('dashboard.Today') }}</el-button>
</div>
<div class="totals">
<span>{{ t('dashboard.Date of data') }}: {{ dateRangeText }}</span>
<b
>{{ t('dashboard.Total Deposit / IN') }}: <em>AUD {{ money(totalDeposit) }}</em></b
>
<b
>{{ t('dashboard.Total Withdraw / OUT') }}: <em>AUD {{ money(totalWithdraw) }}</em></b
>
</div>
</div>
<el-table :data="transactions" border size="small" class="transaction-table" :row-class-name="transactionRowClass">
<el-table-column prop="createdBy" :label="t('dashboard.Created by')" width="110" />
<el-table-column prop="createdTime" :label="t('dashboard.Created Time')" width="170" />
<el-table-column :label="t('dashboard.Category')" width="100">
<template #default="{ row }">{{ categoryText(row.categoryId) }}</template>
</el-table-column>
<el-table-column prop="username" :label="t('dashboard.Username')" width="110" />
<el-table-column prop="remark" :label="t('dashboard.Remark')" min-width="205" />
<el-table-column prop="bank" :label="t('dashboard.Bank')" min-width="165" />
<el-table-column :label="t('dashboard.Type')" width="95">
<template #default="{ row }">{{ typeText(row.typeId) }}</template>
</el-table-column>
<el-table-column :label="t('dashboard.Amount (AUD)')" width="130" align="right">
<template #default="{ row }">
<strong :class="row.flow === 'in' ? 'amount-in' : 'amount-out'">{{ money(row.amount) }}</strong>
</template>
</el-table-column>
<el-table-column :label="t('dashboard.Label')" width="100">
<template #default="{ row }">{{ transactionLabelText(row.labelId) }}</template>
</el-table-column>
<el-table-column :label="t('dashboard.Game Ticket')" min-width="140">
<template #default="{ row }">
<div v-for="(ticket, index) in row.ticket" :key="`${ticket}-${index}`">{{ ticket }}</div>
</template>
</el-table-column>
<el-table-column :label="t('dashboard.Action')" fixed="right" width="180">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openHistory(row)">{{ t('dashboard.History') }}</el-button>
<el-button link type="primary" size="small" @click="openEdit(row)">{{ t('Edit') }}</el-button>
<el-button link type="danger" size="small" @click="removeTransaction(row)">{{ t('Delete') }}</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="transactionPage.currentPage"
background
layout="prev, pager, next"
:total="transactionPage.count"
:page-size="transactionPage.pageSize"
@current-change="onTransactionPageChange"
/>
</div>
</section>
<el-dialog v-model="transactionDialog.visible" :title="transactionDialogTitle" width="680px">
<el-form :model="transactionForm" label-width="145px">
<el-form-item :label="t('dashboard.Date & Time')">
<el-date-picker v-model="transactionForm.time" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" />
<el-radio-group v-model="transactionForm.timeMode" class="inline-mode">
<el-radio value="Auto">{{ t('dashboard.Auto') }}</el-radio>
<el-radio value="Manual">{{ t('dashboard.Manual') }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item :label="t('dashboard.Category')">
<el-select v-model="transactionForm.category">
<el-option :label="t('dashboard.Customer')" :value="1" />
<el-option :label="t('dashboard.Other Adjust')" :value="2" />
</el-select>
</el-form-item>
<el-form-item :label="t('dashboard.Type')">
<el-radio-group v-model="transactionForm.type">
<el-radio-button :value="1">{{ t('dashboard.Deposit') }}</el-radio-button>
<el-radio-button :value="2">{{ t('dashboard.Withdraw') }}</el-radio-button>
<el-radio-button :value="3">IN</el-radio-button>
<el-radio-button :value="4">OUT</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item :label="t('dashboard.Username')">
<el-input
v-model="transactionForm.username"
:disabled="transactionDialog.mode === 'edit'"
:placeholder="t('dashboard.Username placeholder')"
/>
</el-form-item>
<el-form-item :label="t('dashboard.Remark')">
<el-input v-model="transactionForm.remark" :placeholder="t('dashboard.Optional')" />
</el-form-item>
<el-form-item :label="t('dashboard.Amount (AUD)')">
<el-input-number v-model="transactionForm.amount" :min="0" :precision="2" />
</el-form-item>
<el-form-item :label="t('dashboard.Bank')">
<el-select v-model="transactionForm.bank" :placeholder="t('dashboard.Optional select')">
<el-option v-for="bank in banks" :key="bank.id" :label="bank.name" :value="bank.id" />
</el-select>
</el-form-item>
<el-form-item :label="t('dashboard.Label')">
<el-select v-model="transactionForm.label" :placeholder="t('dashboard.Optional select')">
<el-option :label="t('dashboard.First Deposit')" :value="1" />
<el-option :label="t('dashboard.Unclaim')" :value="2" />
</el-select>
</el-form-item>
<el-form-item :label="t('dashboard.Game Ticket Auto')">
<el-checkbox v-model="transactionForm.ticketAuto">{{ t('dashboard.Auto generate ticket') }}</el-checkbox>
</el-form-item>
</el-form>
<template #footer>
<el-button type="primary" :loading="transactionDialog.loading" @click="submitTransaction">{{ t('Confirm') }}</el-button>
<el-button @click="transactionDialog.visible = false">{{ t('Cancel') }}</el-button>
</template>
</el-dialog>
<el-dialog v-model="historyDialog.visible" :title="t('dashboard.Transaction Edit History')" width="720px">
<p class="dialog-note">{{ t('dashboard.History retention note') }}</p>
<el-table v-loading="historyDialog.loading" :data="historyDialog.rows" border size="small" :empty-text="t('dashboard.No Record')">
<el-table-column prop="id" :label="t('dashboard.Tx ID')" width="100" />
<el-table-column prop="editedBy" :label="t('dashboard.Edit By')" width="120" />
<el-table-column prop="editedTime" :label="t('dashboard.Edit Time')" width="170" />
<el-table-column :label="t('dashboard.Changes')">
<template #default="{ row }">{{ historyChangeText(row) }}</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="historyDialog.visible = false">{{ t('Cancel') }}</el-button>
</template>
</el-dialog>
<el-dialog v-model="transferDialog.visible" :title="t('dashboard.Bank Transfer')" width="560px">
<el-form :model="transferForm" label-width="125px">
<el-form-item :label="t('dashboard.Transfer (From)')">
<el-input v-model="transferForm.fromName" disabled />
</el-form-item>
<el-form-item :label="t('dashboard.Transfer (To)')">
<el-select v-model="transferForm.bankTo" :placeholder="t('dashboard.Select Bank')">
<el-option
v-for="bank in banks"
:key="bank.id"
:label="bank.name"
:value="bank.id"
:disabled="bank.id === transferForm.bankFrom"
/>
</el-select>
</el-form-item>
<el-form-item :label="t('dashboard.Amount')">
<el-input-number v-model="transferForm.money" :min="0" :precision="2" />
</el-form-item>
<el-form-item :label="t('dashboard.Remark')">
<el-input v-model="transferForm.remark" :placeholder="t('dashboard.Optional')" />
</el-form-item>
</el-form>
<template #footer>
<el-button type="primary" :loading="transferDialog.loading" @click="submitBankTransfer">{{ t('Confirm') }}</el-button>
<el-button @click="transferDialog.visible = false">{{ t('Cancel') }}</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { Switch } from '@element-plus/icons-vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { bankTransact, delTransact, editTransact, index as getDashboard, logHistory, newTransact } from '/@/api/backend/dashboard'
import type { DashboardTransactPayload } from '/@/api/backend/dashboard'
defineOptions({
name: 'dashboard',
})
const { t } = useI18n()
interface Bank {
id: number | string
name: string
account: string
balance: number
deposit: number
withdraw: number
depositCount: number
withdrawCount: number
alertCode: string
rowClass?: string
labelColor?: string
}
interface DashboardBank {
id: number | string
bank_name?: string
bank_account?: string
balance?: number | string
current_balance?: number | string
tx_in?: number | string
tx_out?: number | string
fund_in?: number | string
fund_out?: number | string
total_fund_in?: number | string
total_fund_out?: number | string
count_fund_in?: number | string
count_fund_out?: number | string
deposit_count?: number | string
withdraw_count?: number | string
safe_alert?: number | string
status?: number | string
label_color?: string
}
interface Transaction {
id: number
createdBy: string
createdTime: string
categoryId: string
username: string
remark: string
bank: string
bankId: number | string
typeId: string
flow: 'in' | 'out'
amount: number
labelId: string
ticket: string[]
}
interface DashboardScoreLog {
game_type_text?: string
money_log_id?: number | string
game_type?: number | string
score?: number | string
}
interface DashboardTransaction {
id: number
user_id?: number | string
money?: number | string
before?: number | string
after?: number | string
type?: number | string
transaction_id?: string
created_by?: string
memo?: string
create_time?: number | string
bank_id?: number | string
category?: number | string
user_name?: string
bank_name?: string
label?: number | string
scoreLog?: DashboardScoreLog[]
}
interface DashboardTransactionPage {
count?: number | string
current_page?: number | string
last_page?: number | string
list?: DashboardTransaction[]
total_deposit?: number | string
total_withdraw?: number | string
}
interface DashboardCustomerSummary {
total_deposit?: number | string
total_withdraw?: number | string
count_deposit?: number | string
count_withdraw?: number | string
active_player?: number | string
first_deposit?: number | string
unclaim_amount?: number | string
unclaim_receipt?: number | string
}
interface HistoryRow {
id: number | string
editedBy: string
editedTime: string
bankBefore: string
bankAfter: string
}
interface CalculationResult {
valid: boolean
value: number
}
const today = () => {
const date = new Date()
const pad = (value: number) => value.toString().padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
const money = (value: number) =>
Number(value).toLocaleString('en-AU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
const toNumber = (value: unknown) => {
const number = Number(value)
return Number.isFinite(number) ? number : 0
}
const normalizeCalculationFormula = (value: string) =>
value
.replace(/,/g, '')
.replace(/乘以/g, '*')
.replace(/除以/g, '/')
.replace(/加/g, '+')
.replace(/减/g, '-')
.replace(/[]/g, '+')
.replace(/[-–—]/g, '-')
.replace(/[×xX]/g, '*')
.replace(/[÷/]/g, '/')
.replace(/\s+/g, '')
.replace(/^=/, '')
const evaluateExpression = (expression: string) => {
let index = 0
const parseNumber = () => {
const matched = expression.slice(index).match(/^(?:\d+(?:\.\d*)?|\.\d+)/)
if (!matched) return null
index += matched[0].length
return Number(matched[0])
}
const parseFactor = (): number | null => {
const char = expression[index]
if (char === '+') {
index += 1
return parseFactor()
}
if (char === '-') {
index += 1
const value = parseFactor()
return value === null ? null : -value
}
if (char === '(') {
index += 1
const value = parseExpression()
if (value === null || expression[index] !== ')') return null
index += 1
return value
}
return parseNumber()
}
const parseTerm = (): number | null => {
let value = parseFactor()
if (value === null) return null
while (expression[index] === '*' || expression[index] === '/') {
const operator = expression[index]
index += 1
const right = parseFactor()
if (right === null) return null
value = operator === '*' ? value * right : right === 0 ? Number.NaN : value / right
}
return value
}
function parseExpression(): number | null {
let value = parseTerm()
if (value === null) return null
while (expression[index] === '+' || expression[index] === '-') {
const operator = expression[index]
index += 1
const right = parseTerm()
if (right === null) return null
value = operator === '+' ? value + right : value - right
}
return value
}
const result = parseExpression()
return result !== null && index === expression.length && Number.isFinite(result) ? result : null
}
const calculateBalancePreview = (balance: number, formula: string): CalculationResult => {
const normalized = normalizeCalculationFormula(formula)
if (!normalized) {
return { valid: true, value: balance }
}
if (!/^[\d+\-*/().]+$/.test(normalized)) {
return { valid: false, value: balance }
}
const expression = /^[+\-*/]/.test(normalized) ? `${balance}${normalized}` : `${balance}+${normalized}`
const value = evaluateExpression(expression)
return value === null ? { valid: false, value: balance } : { valid: true, value }
}
const clampRgbChannel = (value: number) => Math.min(255, Math.max(0, Math.round(value)))
const parseColorChannel = (value: string) => {
const number = Number(value)
return Number.isFinite(number) ? clampRgbChannel(number) : 0
}
const parseAlphaChannel = (value?: string) => {
if (value === undefined) return 1
const number = Number(value)
return Number.isFinite(number) ? Math.min(1, Math.max(0, number)) : 1
}
const parseColorToRgb = (value: string) => {
const color = value.trim()
const hex = color.match(/^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i)
if (hex) {
const raw = hex[1]
const full =
raw.length === 3
? raw
.split('')
.map((char) => char + char)
.join('')
: raw
return {
r: Number.parseInt(full.slice(0, 2), 16),
g: Number.parseInt(full.slice(2, 4), 16),
b: Number.parseInt(full.slice(4, 6), 16),
a: full.length === 8 ? Number.parseInt(full.slice(6, 8), 16) / 255 : 1,
}
}
const rgb = color.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/i)
if (!rgb) return null
return {
r: parseColorChannel(rgb[1]),
g: parseColorChannel(rgb[2]),
b: parseColorChannel(rgb[3]),
a: parseAlphaChannel(rgb[4]),
}
}
const rgbToCss = (rgb: { r: number; g: number; b: number }) => `rgb(${clampRgbChannel(rgb.r)}, ${clampRgbChannel(rgb.g)}, ${clampRgbChannel(rgb.b)})`
const mixRgb = (source: { r: number; g: number; b: number }, target: { r: number; g: number; b: number }, targetWeight: number) => ({
r: source.r * (1 - targetWeight) + target.r * targetWeight,
g: source.g * (1 - targetWeight) + target.g * targetWeight,
b: source.b * (1 - targetWeight) + target.b * targetWeight,
})
const polishedBankRowColor = (backgroundColor: string) => {
const rgb = parseColorToRgb(backgroundColor)
if (!rgb) return backgroundColor
const blended = {
r: rgb.r * rgb.a + 255 * (1 - rgb.a),
g: rgb.g * rgb.a + 255 * (1 - rgb.a),
b: rgb.b * rgb.a + 255 * (1 - rgb.a),
}
const max = Math.max(blended.r, blended.g, blended.b)
const min = Math.min(blended.r, blended.g, blended.b)
if (max - min < 18) {
return rgbToCss(mixRgb(blended, { r: 241, g: 245, b: 249 }, 0.72))
}
if (blended.g >= blended.r && blended.g >= blended.b) {
return '#d9f3e5'
}
if (blended.r >= blended.g && blended.r >= blended.b) {
return '#f8dde2'
}
return '#dbeafe'
}
const srgbToLinear = (channel: number) => {
const value = channel / 255
return value <= 0.03928 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4)
}
const relativeLuminance = (rgb: { r: number; g: number; b: number }) =>
0.2126 * srgbToLinear(rgb.r) + 0.7152 * srgbToLinear(rgb.g) + 0.0722 * srgbToLinear(rgb.b)
const contrastRatio = (first: number, second: number) => {
const lighter = Math.max(first, second)
const darker = Math.min(first, second)
return (lighter + 0.05) / (darker + 0.05)
}
const readableTextColor = (backgroundColor: string) => {
const rgb = parseColorToRgb(backgroundColor)
if (!rgb) return '#172033'
const blended = {
r: rgb.r * rgb.a + 255 * (1 - rgb.a),
g: rgb.g * rgb.a + 255 * (1 - rgb.a),
b: rgb.b * rgb.a + 255 * (1 - rgb.a),
}
const luminance = relativeLuminance(blended)
const slateContrast = contrastRatio(luminance, relativeLuminance({ r: 23, g: 32, b: 51 }))
const whiteContrast = contrastRatio(luminance, 1)
const darkContrast = contrastRatio(luminance, 0)
if (slateContrast >= 4.5 || slateContrast >= whiteContrast) {
return '#172033'
}
return whiteContrast >= darkContrast ? '#ffffff' : '#000000'
}
const bankRowStyle = (bank: Bank): Record<string, string> => {
if (!bank.labelColor) return {}
const backgroundColor = polishedBankRowColor(bank.labelColor)
const textColor = readableTextColor(backgroundColor)
const isLightText = textColor === '#ffffff'
return {
backgroundColor,
'--bank-row-text-color': textColor,
'--bank-row-muted-color': isLightText ? 'rgba(255, 255, 255, 0.82)' : 'rgba(0, 0, 0, 0.72)',
'--bank-row-border-color': isLightText ? 'rgba(255, 255, 255, 0.24)' : 'rgba(0, 0, 0, 0.16)',
'--bank-row-control-bg': isLightText ? 'rgba(255, 255, 255, 0.16)' : 'rgba(255, 255, 255, 0.72)',
'--bank-row-control-hover-bg': isLightText ? 'rgba(255, 255, 255, 0.26)' : 'rgba(255, 255, 255, 0.88)',
}
}
const formatDateTime = (value: unknown) => {
if (typeof value === 'string' && value.trim() && !Number.isFinite(Number(value))) {
return value
}
const timestamp = toNumber(value)
if (!timestamp) return ''
const date = new Date(timestamp * 1000)
const pad = (number: number) => number.toString().padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
const mapBank = (bank: DashboardBank): Bank => {
const fundIn = toNumber(bank.total_fund_in ?? bank.fund_in)
const fundOut = toNumber(bank.total_fund_out ?? bank.fund_out)
const safeAlert = String(bank.safe_alert ?? '0')
return {
id: bank.id,
name: bank.bank_name || '-',
account: bank.bank_account || '',
balance: toNumber(bank.current_balance ?? bank.balance ?? fundIn - fundOut),
deposit: fundIn,
withdraw: fundOut,
depositCount: toNumber(bank.count_fund_in ?? bank.deposit_count ?? bank.tx_in),
withdrawCount: toNumber(bank.count_fund_out ?? bank.withdraw_count ?? bank.tx_out),
alertCode: safeAlert,
rowClass: String(bank.status ?? '1') === '0' ? 'bank-muted' : '',
labelColor: bank.label_color || '',
}
}
const banks = ref<Bank[]>([])
const transactions = ref<Transaction[]>([])
const customerSummary = reactive({
totalDeposit: 0,
totalWithdraw: 0,
countDeposit: 0,
countWithdraw: 0,
activePlayer: 0,
firstDeposit: 0,
unclaimAmount: 0,
unclaimReceipt: 0,
})
const transactionTotals = reactive({
totalDeposit: 0,
totalWithdraw: 0,
})
const filters = reactive({
startDate: today(),
endDate: today(),
})
const transactionPage = reactive({
count: 0,
currentPage: 1,
lastPage: 1,
pageSize: 10,
})
const showAllBanks = ref(false)
const calculatorFormulas = reactive<Record<string, string>>({})
const visibleBanks = computed(() => (showAllBanks.value ? banks.value : banks.value.slice(0, 4)))
const totalDeposit = computed(() => transactionTotals.totalDeposit)
const totalWithdraw = computed(() => transactionTotals.totalWithdraw)
const dateRangeText = computed(() => {
const start = new Date(`${filters.startDate}T00:00:00`)
const end = new Date(`${filters.endDate}T00:00:00`)
const diffDays = Math.max(1, Math.floor((end.getTime() - start.getTime()) / 86400000) + 1)
return t('dashboard.Date range', {
start: filters.startDate,
end: filters.endDate,
days: diffDays,
unit: t(diffDays === 1 ? 'dashboard.Day' : 'dashboard.Days'),
})
})
const summary = computed(() => [
{ label: t('dashboard.Total Deposit / IN'), value: `AUD ${money(customerSummary.totalDeposit)}` },
{ label: t('dashboard.Total Withdraw / OUT'), value: `AUD ${money(customerSummary.totalWithdraw)}` },
{ label: t('dashboard.Deposit Count'), value: customerSummary.countDeposit },
{ label: t('dashboard.Withdraw Count'), value: customerSummary.countWithdraw },
{ label: t('dashboard.Active Player'), value: customerSummary.activePlayer },
{ label: t('dashboard.First Deposit Player'), value: customerSummary.firstDeposit },
{ label: t('dashboard.Unclaimed Amount'), value: `AUD ${money(customerSummary.unclaimAmount)}` },
{ label: t('dashboard.Unclaimed Receipt'), value: customerSummary.unclaimReceipt },
])
const calculatorKey = (bank: Bank) => String(bank.id)
const calculatorToggleId = (bank: Bank) => `bank-calculator-${calculatorKey(bank).replace(/[^a-zA-Z0-9_-]/g, '-')}`
const calculatorResult = (bank: Bank) => calculateBalancePreview(bank.balance, calculatorFormulas[calculatorKey(bank)] || '')
const isCalculatorInvalid = (bank: Bank) => !calculatorResult(bank).valid
const calculatorResultText = (bank: Bank) => {
const result = calculatorResult(bank)
return result.valid ? `AUD ${money(result.value)}` : 'AUD --'
}
const transactionDialog = reactive<{ visible: boolean; loading: boolean; mode: 'create' | 'edit'; editId: number | string | '' }>({
visible: false,
loading: false,
mode: 'create',
editId: '',
})
const transactionDialogTitle = computed(() =>
transactionDialog.mode === 'edit' ? t('dashboard.Edit Transaction', { id: transactionDialog.editId }) : t('dashboard.Create New Transaction')
)
const transactionForm = reactive({
time: '',
timeMode: 'Auto',
category: 1,
type: 1,
username: '',
remark: '',
amount: 0,
bank: '',
label: '',
ticketAuto: true,
})
const historyDialog = reactive({
visible: false,
loading: false,
rows: [] as HistoryRow[],
})
const transferDialog = reactive({ visible: false, loading: false })
const transferForm = reactive<{ bankFrom: Bank['id'] | ''; fromName: string; bankTo: Bank['id'] | ''; money: number; remark: string }>({
bankFrom: '',
fromName: '',
bankTo: '',
money: 0,
remark: '',
})
const safeAlertText = (code: string) => {
const labels: Record<string, string> = {
'1': t('dashboard.Hourly Alert'),
'2': t('dashboard.Daily Alert'),
'3': t('dashboard.Weekly Alert'),
'4': t('dashboard.Monthly Alert'),
'5': t('dashboard.Yearly Alert'),
'6': t('dashboard.Lifetime Alert'),
}
return labels[code] || t('dashboard.No Alert')
}
const typeText = (type: string) => {
const labels: Record<string, string> = {
'1': t('dashboard.Deposit'),
'2': t('dashboard.Withdraw'),
'3': 'IN',
'4': 'OUT',
}
return labels[type] || type
}
const categoryText = (category: string) => {
const labels: Record<string, string> = {
'1': t('dashboard.Customer'),
'2': t('dashboard.Other Adjust'),
}
return labels[category] || category
}
const transactionLabelText = (label: string) => {
const labels: Record<string, string> = {
'1': t('dashboard.First Deposit'),
'2': t('dashboard.Unclaim'),
}
return labels[label] || ''
}
const mapTransaction = (transaction: DashboardTransaction): Transaction => {
const type = String(transaction.type ?? '')
return {
id: transaction.id,
createdBy: transaction.created_by || '',
createdTime: formatDateTime(transaction.create_time),
categoryId: String(transaction.category ?? ''),
username: transaction.user_name || '',
remark: transaction.memo || '',
bank: transaction.bank_name || '',
bankId: transaction.bank_id || '',
typeId: type,
flow: ['1', '3'].includes(type) ? 'in' : 'out',
amount: toNumber(transaction.money),
labelId: String(transaction.label ?? ''),
ticket: Array.isArray(transaction.scoreLog)
? transaction.scoreLog.map((score) => `${score.game_type_text || ''} : ${score.score ?? ''}`).filter((score) => score.trim() !== ':')
: [],
}
}
const loadDashboard = (page = transactionPage.currentPage) => {
return getDashboard({
start: filters.startDate,
end: filters.endDate,
page,
}).then((res) => {
const bankData = Array.isArray(res.data.bank) ? res.data.bank : res.data.bank?.list
banks.value = Array.isArray(bankData) ? bankData.map(mapBank) : []
const transactionData = res.data.transaction as DashboardTransactionPage | undefined
const transactionList = Array.isArray(transactionData?.list) ? transactionData.list : []
transactions.value = transactionList.map(mapTransaction)
transactionPage.count = toNumber(transactionData?.count)
transactionPage.currentPage = toNumber(transactionData?.current_page) || page
transactionPage.lastPage = toNumber(transactionData?.last_page) || 1
transactionPage.pageSize = transactionPage.lastPage > 0 ? Math.max(1, Math.ceil(transactionPage.count / transactionPage.lastPage)) : 10
transactionTotals.totalDeposit = toNumber(transactionData?.total_deposit)
transactionTotals.totalWithdraw = toNumber(transactionData?.total_withdraw)
const customerData = res.data.customer as DashboardCustomerSummary | undefined
customerSummary.totalDeposit = toNumber(customerData?.total_deposit)
customerSummary.totalWithdraw = toNumber(customerData?.total_withdraw)
customerSummary.countDeposit = toNumber(customerData?.count_deposit)
customerSummary.countWithdraw = toNumber(customerData?.count_withdraw)
customerSummary.activePlayer = toNumber(customerData?.active_player)
customerSummary.firstDeposit = toNumber(customerData?.first_deposit)
customerSummary.unclaimAmount = toNumber(customerData?.unclaim_amount)
customerSummary.unclaimReceipt = toNumber(customerData?.unclaim_receipt)
})
}
const resetTransactionForm = () => {
Object.assign(transactionForm, {
time: '',
timeMode: 'Auto',
category: 1,
type: 1,
username: '',
remark: '',
amount: 0,
bank: '',
label: '',
ticketAuto: true,
})
}
const openCreate = () => {
resetTransactionForm()
transactionDialog.mode = 'create'
transactionDialog.editId = ''
transactionDialog.visible = true
}
const openEdit = (row: Transaction) => {
Object.assign(transactionForm, {
time: row.createdTime,
timeMode: 'Manual',
category: toNumber(row.categoryId) || 1,
type: toNumber(row.typeId) || 1,
username: row.username,
remark: row.remark,
amount: row.amount,
bank: row.bankId || banks.value.find((bank) => bank.name === row.bank)?.id || '',
label: row.labelId ? toNumber(row.labelId) : '',
ticketAuto: row.ticket.length > 0,
})
transactionDialog.mode = 'edit'
transactionDialog.editId = row.id
transactionDialog.visible = true
}
const transactionTimestamp = () => {
if (transactionForm.timeMode === 'Auto' || !transactionForm.time) {
return Math.floor(Date.now() / 1000)
}
const date = new Date(transactionForm.time.replace(' ', 'T'))
const timestamp = Math.floor(date.getTime() / 1000)
return Number.isFinite(timestamp) ? timestamp : Math.floor(Date.now() / 1000)
}
const buildTransactPayload = (): DashboardTransactPayload => ({
create_time: transactionTimestamp(),
category: transactionForm.category,
type: transactionForm.type,
user_name: transactionForm.username,
memo: transactionForm.remark,
money: transactionForm.amount,
bank_id: transactionForm.bank,
label: transactionForm.label,
game_ticket: transactionForm.ticketAuto ? 1 : 0,
})
const submitTransaction = () => {
transactionDialog.loading = true
const request =
transactionDialog.mode === 'edit' && transactionDialog.editId !== ''
? editTransact({ ...buildTransactPayload(), id: transactionDialog.editId })
: newTransact(buildTransactPayload())
request
.then(() => {
transactionDialog.visible = false
transactionPage.currentPage = 1
return loadDashboard(1)
})
.finally(() => {
transactionDialog.loading = false
})
}
const mapHistory = (history: Record<string, unknown>): HistoryRow => ({
id: (history.id || history.money_log_id || '') as number | string,
editedBy: String(history.admin_name || ''),
editedTime: formatDateTime(history.create_time),
bankBefore: String(history.bank_befter ?? ''),
bankAfter: String(history.bank_after ?? ''),
})
const historyChangeText = (row: HistoryRow) => t('dashboard.Bank change', { before: row.bankBefore, after: row.bankAfter })
const openHistory = (row: Transaction) => {
historyDialog.visible = true
historyDialog.loading = true
historyDialog.rows = []
logHistory({ id: row.id })
.then((res) => {
const data = Array.isArray(res.data) ? res.data : res.data?.list
historyDialog.rows = Array.isArray(data) ? data.map((item) => mapHistory(item as Record<string, unknown>)) : []
})
.finally(() => {
historyDialog.loading = false
})
}
const openTransfer = (bank: Bank) => {
Object.assign(transferForm, { bankFrom: bank.id, fromName: bank.name, bankTo: '', money: 0, remark: '' })
transferDialog.visible = true
}
const submitBankTransfer = () => {
transferDialog.loading = true
bankTransact({
money: transferForm.money,
bank_from: transferForm.bankFrom,
bank_to: transferForm.bankTo,
remark: transferForm.remark,
})
.then(() => {
transferDialog.visible = false
return loadDashboard()
})
.finally(() => {
transferDialog.loading = false
})
}
const removeTransaction = (row: Transaction) => {
delTransact({ id: row.id }).then(() => {
return loadDashboard(transactionPage.currentPage)
})
}
const setToday = () => {
filters.startDate = today()
filters.endDate = today()
transactionPage.currentPage = 1
loadDashboard(1).catch(() => {
// Request errors are displayed by the shared Axios interceptor.
})
}
const search = () => {
transactionPage.currentPage = 1
loadDashboard(1).catch(() => {
// Request errors are displayed by the shared Axios interceptor.
})
}
const onTransactionPageChange = (page: number) => {
loadDashboard(page).catch(() => {
// Request errors are displayed by the shared Axios interceptor.
})
}
const transactionRowClass = ({ row }: { row: Transaction }) => (row.flow === 'in' ? 'transaction-in' : 'transaction-out')
onMounted(() => {
loadDashboard().catch(() => {
// Request errors are displayed by the shared Axios interceptor.
})
})
</script>
<style scoped lang="scss">
.bookkeeping-dashboard {
color: var(--el-text-color-primary);
font-size: 13px;
font-variant-numeric: tabular-nums;
}
.dashboard-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 310px;
gap: var(--ba-main-space);
}
.dashboard-panel,
.transaction-section {
border: 1px solid var(--ba-border-color-soft);
border-radius: var(--ba-radius-panel);
background: var(--ba-bg-color-overlay);
box-shadow: var(--ba-shadow-card);
overflow: hidden;
}
.panel-title {
padding: 13px 16px;
border-bottom: 1px solid var(--ba-border-color-soft);
background: var(--ba-bg-color-soft);
color: var(--el-text-color-primary);
font-size: 15px;
font-weight: 700;
line-height: 1.4;
}
.table-scroll {
overflow-x: auto;
}
.bank-table {
width: 100%;
border-collapse: collapse;
th,
td {
padding: 11px 14px;
border-bottom: 1px solid var(--ba-border-color-soft);
text-align: left;
vertical-align: middle;
}
th {
color: var(--el-text-color-secondary);
font-size: 12px;
font-weight: 700;
background: var(--ba-bg-color-soft);
white-space: nowrap;
}
tbody tr {
transition: background-color 0.18s ease;
}
tbody tr:hover:not(.bank-colored) {
background: var(--el-color-primary-light-9);
}
tbody tr.bank-colored {
color: var(--bank-row-text-color);
text-shadow: none;
td {
border-bottom-color: var(--bank-row-border-color);
}
small {
color: var(--bank-row-muted-color);
}
.balance {
color: inherit;
}
:deep(.el-button) {
--el-button-text-color: var(--bank-row-text-color);
--el-button-bg-color: var(--bank-row-control-bg);
--el-button-border-color: var(--bank-row-border-color);
--el-button-hover-text-color: var(--bank-row-text-color);
--el-button-hover-bg-color: var(--bank-row-control-hover-bg);
--el-button-hover-border-color: var(--bank-row-border-color);
--el-button-active-text-color: var(--bank-row-text-color);
--el-button-active-bg-color: var(--bank-row-control-hover-bg);
--el-button-active-border-color: var(--bank-row-border-color);
}
.calculator-button {
color: var(--bank-row-text-color);
border-color: var(--bank-row-border-color);
background: var(--bank-row-control-bg);
&:hover,
&:focus-visible {
background: var(--bank-row-control-hover-bg);
}
}
:deep(.el-tag) {
color: var(--bank-row-text-color);
border-color: var(--bank-row-border-color);
background: var(--bank-row-control-bg);
}
}
strong,
small {
display: block;
}
small {
margin-top: 3px;
color: var(--el-text-color-secondary);
}
.balance {
color: var(--el-color-primary);
font-weight: 700;
white-space: nowrap;
}
.empty-banks {
padding: 24px;
color: var(--el-text-color-secondary);
text-align: center;
}
}
.bank-muted {
background: var(--el-fill-color-lighter);
}
.bank-operate {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
:deep(.el-button.is-circle) {
width: 30px;
height: 30px;
}
}
.bank-operate-main {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.calculator-toggle {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.calculator-toggle:not(:checked) ~ .bank-calculator {
display: none;
}
.calculator-toggle:checked + .bank-operate-main .calculator-button {
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
.calculator-toggle:focus-visible + .bank-operate-main .calculator-button {
outline: 2px solid var(--el-color-primary-light-5);
outline-offset: 2px;
}
.calculator-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
border: 1px solid var(--el-border-color);
border-radius: 50%;
background: var(--el-bg-color);
color: var(--el-text-color-regular);
cursor: pointer;
line-height: 1;
transition:
background-color 0.18s ease,
border-color 0.18s ease,
color 0.18s ease;
&:hover,
&:focus-visible {
border-color: var(--el-color-primary);
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
outline: none;
}
}
.bank-calculator {
width: min(300px, 100%);
padding: 6px 8px 5px;
border: 1px solid var(--ba-border-color-soft);
border-radius: 2px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.08);
color: var(--el-text-color-primary);
}
.calculator-formula {
display: flex;
align-items: center;
gap: 5px;
}
.calculator-input {
flex: 1 1 125px;
min-width: 112px;
:deep(.el-input__wrapper) {
min-height: 28px;
border-radius: 0;
box-shadow: 0 0 0 1px rgba(148, 163, 184, 0.72) inset;
}
}
.calculator-equals {
color: var(--el-text-color-primary);
font-size: 15px;
font-weight: 700;
line-height: 1;
}
.calculator-result {
min-width: 92px;
color: var(--el-text-color-primary);
font-size: 15px;
font-weight: 700;
line-height: 1;
white-space: nowrap;
&.is-invalid {
color: var(--el-color-danger);
}
}
.calculator-hint {
margin-top: 3px;
color: var(--el-text-color-regular);
font-size: 11px;
line-height: 1.2;
span {
margin-left: 4px;
font-size: 12px;
font-weight: 700;
letter-spacing: 1px;
}
}
.breakdown {
display: inline-flex;
flex-direction: column;
gap: 4px;
min-width: 118px;
padding: 5px 8px;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 4px;
background: rgba(248, 250, 252, 0.94);
color: var(--el-text-color-regular);
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.04);
}
.breakdown-row {
display: grid;
grid-template-columns: 18px auto minmax(0, 1fr);
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 700;
line-height: 1.15;
white-space: nowrap;
}
.breakdown-label {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 3px;
color: #ffffff;
font-size: 11px;
font-weight: 700;
}
.breakdown-count {
color: var(--el-text-color-secondary);
font-weight: 600;
}
.breakdown-in {
color: var(--el-color-success);
.breakdown-label {
background: var(--el-color-success);
}
}
.breakdown-out {
color: var(--el-color-danger);
.breakdown-label {
background: var(--el-color-danger);
}
}
.dot {
font-style: normal;
font-weight: 700;
}
.income,
.amount-in {
color: var(--el-color-success);
}
.outcome,
.amount-out {
color: var(--el-color-danger);
}
.more-info {
display: block;
width: 100%;
padding: 11px;
border: 0;
border-top: 1px solid var(--ba-border-color-soft);
background: var(--ba-bg-color-soft);
color: var(--el-color-primary);
cursor: pointer;
font-weight: 700;
transition: background-color 0.18s ease;
&:hover {
background: var(--el-color-primary-light-9);
}
}
.summary-side {
display: flex;
flex-direction: column;
gap: var(--ba-main-space);
}
.summary-panel dl {
display: grid;
grid-template-columns: 1fr auto;
gap: 0;
margin: 0;
}
.summary-panel dt,
.summary-panel dd {
margin: 0;
padding: 10px 14px;
border-bottom: 1px solid var(--ba-border-color-soft);
}
.summary-panel dt {
color: var(--el-text-color-secondary);
font-weight: 600;
}
.summary-panel dd {
color: var(--el-color-primary);
font-weight: 700;
text-align: right;
}
.create-button {
width: 100%;
min-height: 46px;
font-weight: 700;
}
.webhook-alert {
margin: var(--ba-main-space) 0;
border: 1px solid rgba(183, 121, 31, 0.24);
p {
margin: 6px 0 0;
line-height: 1.5;
}
}
.filter-row {
display: flex;
justify-content: space-between;
gap: 14px;
padding: 14px 16px;
border-bottom: 1px solid var(--ba-border-color-soft);
background: var(--ba-bg-color-soft);
}
.date-filter,
.totals {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.date-filter :deep(.el-date-editor) {
width: 150px;
}
.date-filter label {
color: var(--el-text-color-secondary);
font-weight: 600;
}
.totals {
justify-content: flex-end;
color: var(--el-text-color-regular);
b {
font-weight: 700;
}
em {
color: var(--el-color-primary);
font-style: normal;
}
}
.transaction-table :deep(.transaction-in) {
--el-table-tr-bg-color: var(--el-color-success-light-9);
}
.transaction-table :deep(.transaction-out) {
--el-table-tr-bg-color: var(--el-color-danger-light-9);
}
.pagination {
display: flex;
justify-content: flex-end;
padding: 14px 16px;
border-top: 1px solid var(--ba-border-color-soft);
background: var(--ba-bg-color-soft);
}
.inline-mode {
margin-left: 12px;
}
.dialog-note {
margin: 0 0 8px;
color: var(--el-text-color-secondary);
}
:deep(.el-dialog__body) {
padding-top: 16px;
}
:deep(.el-form-item .el-select),
:deep(.el-form-item .el-input) {
width: 100%;
}
@media screen and (max-width: 1100px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
}
@media screen and (max-width: 720px) {
.filter-row {
flex-direction: column;
}
.date-filter {
align-items: stretch;
label {
width: 100%;
}
:deep(.el-date-editor),
:deep(.el-button) {
width: 100%;
}
}
.totals {
justify-content: flex-start;
}
.bank-table {
min-width: 760px;
}
.pagination {
justify-content: center;
}
}
</style>