Files
vps-tracker/src/pages/BalancePage.jsx
T
2026-03-17 23:19:28 +07:00

292 lines
11 KiB
React

import { useMemo, useState } from 'react'
import {
billingModeLabel,
paymentTypeLabel,
} from '../lib/utils'
import { UiModal } from '../components/UiModal'
import { EmptyState } from '../components/EmptyState'
import { PageHeader } from '../components/PageHeader'
import { ConvertedAmount } from '../components/ConvertedAmount'
const emptyForm = {
type: 'daily_debit',
providerAccountId: '',
vpsId: '',
date: new Date().toISOString().slice(0, 10),
amount: '',
currency: 'USD',
note: '',
}
export function BalancePage({ db, actions, settings, ratesData }) {
const [form, setForm] = useState(emptyForm)
const [error, setError] = useState('')
const [isModalOpen, setIsModalOpen] = useState(false)
const vpsOptions = useMemo(
() => db.vps.filter((vps) => !form.providerAccountId || vps.providerAccountId === form.providerAccountId),
[db.vps, form.providerAccountId],
)
const accountBalances = useMemo(() => {
return db.providerAccounts.map((account) => {
const records = db.balanceLedger.filter((item) => item.providerAccountId === account.id)
const value = records.reduce((acc, row) => {
const amount = Number(row.amount || 0)
return row.direction === 'credit' ? acc + amount : acc - amount
}, 0)
return { ...account, balance: value }
})
}, [db.balanceLedger, db.providerAccounts])
const onSubmit = (event) => {
event.preventDefault()
const amount = Number(form.amount)
if (!form.providerAccountId) {
setError('Выберите аккаунт')
return
}
if (!Number.isFinite(amount) || amount <= 0) {
setError('Сумма должна быть больше 0')
return
}
setError('')
actions.create('balanceLedger', {
type: form.type,
providerAccountId: form.providerAccountId,
vpsId: form.vpsId || '',
date: form.date,
amount,
currency: form.currency,
direction: 'debit',
note: form.note,
})
setForm(emptyForm)
setIsModalOpen(false)
}
const directionLabel = (direction) => (direction === 'credit' ? 'Пополнение' : 'Списание')
return (
<>
<PageHeader pretitle="Финансы" title="Баланс и списания" />
<div className="row row-cards">
<div className="col-12 card-stack">
<div className="card">
<div className="card-header">
<h3 className="card-title">Баланс по аккаунтам</h3>
<div className="card-actions">
<button className="btn btn-primary" type="button" onClick={() => setIsModalOpen(true)}>
Добавить списание
</button>
</div>
</div>
<div className="table-responsive">
<table className="table card-table table-vcenter">
<thead>
<tr>
<th>Аккаунт</th>
<th>Режим</th>
<th>Баланс</th>
</tr>
</thead>
<tbody>
{accountBalances.map((account) => (
<tr key={account.id}>
<td>{account.name}</td>
<td>{billingModeLabel(account.billingMode)}</td>
<td>
<ConvertedAmount
amount={account.balance}
currency={account.currency}
provider={db.providers.find((item) => item.id === account.providerId)}
settings={settings}
ratesData={ratesData}
/>
</td>
</tr>
))}
{accountBalances.length === 0 ? (
<EmptyState message="Нет аккаунтов" colSpan={3} />
) : null}
</tbody>
</table>
</div>
</div>
<div className="card">
<div className="card-header">
<h3 className="card-title">Журнал операций</h3>
</div>
<div className="table-responsive">
<table className="table card-table table-vcenter">
<thead>
<tr>
<th>Дата</th>
<th>Тип</th>
<th>Направление</th>
<th>Аккаунт / VPS</th>
<th>Сумма</th>
<th />
</tr>
</thead>
<tbody>
{db.balanceLedger.map((row) => {
const account = db.providerAccounts.find((item) => item.id === row.providerAccountId)
const provider = db.providers.find((item) => item.id === account?.providerId)
const vps = db.vps.find((item) => item.id === row.vpsId)
return (
<tr key={row.id}>
<td>{row.date}</td>
<td>{paymentTypeLabel(row.type)}</td>
<td>
<span className={`badge ${row.direction === 'credit' ? 'bg-green-lt' : 'bg-red-lt'}`}>
{directionLabel(row.direction)}
</span>
</td>
<td>
<div>{account?.name || '-'}</div>
<div className="text-secondary">{vps?.dns || vps?.ip || '-'}</div>
</td>
<td>
<ConvertedAmount
amount={row.amount}
currency={row.currency}
provider={provider}
settings={settings}
ratesData={ratesData}
/>
</td>
<td className="text-end">
<div className="table-actions">
<button
className="btn btn-sm btn-outline-danger"
type="button"
onClick={() => actions.remove('balanceLedger', row.id)}
>
Удалить
</button>
</div>
</td>
</tr>
)
})}
{db.balanceLedger.length === 0 ? (
<EmptyState message="Журнал операций пуст" colSpan={6} />
) : null}
</tbody>
</table>
</div>
</div>
</div>
</div>
<UiModal
open={isModalOpen}
title="Добавить списание"
onClose={() => {
setIsModalOpen(false)
setError('')
setForm(emptyForm)
}}
size="modal-md"
>
<form className="row g-3" onSubmit={onSubmit}>
<div className="col-12">
<label className="form-label">Тип списания</label>
<select
className="form-select"
value={form.type}
onChange={(e) => setForm((prev) => ({ ...prev, type: e.target.value }))}
>
<option value="daily_debit">Ежедневное списание</option>
<option value="monthly_debit">Ежемесячное списание</option>
</select>
</div>
<div className="col-12">
<label className="form-label">Аккаунт</label>
<select
className="form-select"
value={form.providerAccountId}
onChange={(e) =>
setForm((prev) => ({ ...prev, providerAccountId: e.target.value, vpsId: '' }))
}
>
<option value="">Выберите аккаунт</option>
{db.providerAccounts.map((account) => (
<option key={account.id} value={account.id}>
{account.name}
</option>
))}
</select>
</div>
<div className="col-12">
<label className="form-label">VPS (необязательно)</label>
<select
className="form-select"
value={form.vpsId}
onChange={(e) => setForm((prev) => ({ ...prev, vpsId: e.target.value }))}
>
<option value="">Без привязки</option>
{vpsOptions.map((vps) => (
<option key={vps.id} value={vps.id}>
{vps.dns || vps.ip}
</option>
))}
</select>
</div>
<div className="col-12 col-sm-6">
<label className="form-label">Дата</label>
<input
className="form-control"
type="date"
value={form.date}
onChange={(e) => setForm((prev) => ({ ...prev, date: e.target.value }))}
/>
</div>
<div className="col-12 col-sm-6">
<label className="form-label">Валюта</label>
<select
className="form-select"
value={form.currency}
onChange={(e) => setForm((prev) => ({ ...prev, currency: e.target.value }))}
>
<option>USD</option>
<option>EUR</option>
<option>RUB</option>
</select>
</div>
<div className="col-12">
<label className="form-label">Сумма</label>
<input
type="number"
min="0.01"
step="0.01"
className="form-control"
value={form.amount}
onChange={(e) => setForm((prev) => ({ ...prev, amount: e.target.value }))}
/>
</div>
<div className="col-12">
<label className="form-label">Комментарий</label>
<textarea
className="form-control"
value={form.note}
onChange={(e) => setForm((prev) => ({ ...prev, note: e.target.value }))}
/>
</div>
{error ? <div className="col-12 text-danger small">{error}</div> : null}
<div className="col-12 d-flex gap-2 justify-content-end">
<button type="button" className="btn btn-outline-secondary" onClick={() => setIsModalOpen(false)}>
Отмена
</button>
<button className="btn btn-primary" type="submit">
Добавить списание
</button>
</div>
</form>
</UiModal>
</>
)
}