refactor(currency): валюта тарифа = валюта хостера, не VPS
- effectiveVpsTariffCurrency всегда берёт provider.baseCurrency - Убран селектор валюты из формы VPS (подпись из хостера) - DashboardPage, ResourcesPage, ReportsPage: вместо item.currency — baseCurrency провайдера - Конвертация по глобальным курсам теперь корректно находит EUR/USD Made-with: Cursor
This commit is contained in:
+6
-13
@@ -174,22 +174,15 @@ function toIsoCurrency(currency) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Валюта суммы тарифа VPS: поле vps.currency, иначе (только у хостеров без API) — валюта приёма из справочника.
|
* Валюта тарифа VPS = валюта хостера (provider.baseCurrency).
|
||||||
* Для BILLmanager нельзя подставлять baseCurrency хостера при пустом поле: тариф в валюте из API (часто RUB).
|
* vps.currency — только как запасной вариант, если хостер не найден.
|
||||||
* У ручных хостеров «USD» в VPS часто дефолт формы — тогда берём валюту хостера.
|
|
||||||
*/
|
*/
|
||||||
export function effectiveVpsTariffCurrency(vps, provider) {
|
export function effectiveVpsTariffCurrency(vps, provider) {
|
||||||
const ownRaw = (vps?.currency || '').trim()
|
|
||||||
const ownIso = ownRaw ? toIsoCurrency(ownRaw) : null
|
|
||||||
const provRaw = (provider?.baseCurrency || '').trim()
|
const provRaw = (provider?.baseCurrency || '').trim()
|
||||||
const provIso = provRaw ? toIsoCurrency(provRaw) : null
|
if (provRaw) return toIsoCurrency(provRaw)
|
||||||
const noApi = !(provider?.apiType || '').trim()
|
const ownRaw = (vps?.currency || '').trim()
|
||||||
if (noApi && ownIso === 'USD' && provIso && provIso !== 'USD') {
|
if (ownRaw) return toIsoCurrency(ownRaw)
|
||||||
return provIso
|
return 'RUB'
|
||||||
}
|
|
||||||
if (ownIso) return ownIso
|
|
||||||
if (provIso && noApi) return provIso
|
|
||||||
return noApi ? 'USD' : 'RUB'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { convertCurrency, formatCurrency, monthKey } from '../lib/utils'
|
import { convertCurrency, effectiveVpsTariffCurrency, formatCurrency, monthKey } from '../lib/utils'
|
||||||
import { getPaidUntilDate as computePaidUntil } from '../lib/paid-until'
|
import { getPaidUntilDate as computePaidUntil } from '../lib/paid-until'
|
||||||
import { computeInventoryHealth, formatSyncSummaryLine } from '../lib/inventory-health'
|
import { computeInventoryHealth, formatSyncSummaryLine } from '../lib/inventory-health'
|
||||||
import { fetchSyncStatus } from '../lib/api'
|
import { fetchSyncStatus } from '../lib/api'
|
||||||
@@ -37,6 +37,11 @@ export function DashboardPage({ db = {}, settings, ratesData }) {
|
|||||||
|
|
||||||
const activeVpsCount = vps.filter((item) => item.status === 'active').length
|
const activeVpsCount = vps.filter((item) => item.status === 'active').length
|
||||||
|
|
||||||
|
const providerById = useMemo(
|
||||||
|
() => new Map(providers.map((p) => [p.id, p])),
|
||||||
|
[providers],
|
||||||
|
)
|
||||||
|
|
||||||
const monthForecast = useMemo(() => {
|
const monthForecast = useMemo(() => {
|
||||||
return vps
|
return vps
|
||||||
.filter((item) => item.status === 'active')
|
.filter((item) => item.status === 'active')
|
||||||
@@ -46,9 +51,10 @@ export function DashboardPage({ db = {}, settings, ratesData }) {
|
|||||||
tariffType === 'daily'
|
tariffType === 'daily'
|
||||||
? Number(item.dailyRate || 0) * 30
|
? Number(item.dailyRate || 0) * 30
|
||||||
: Number(item.monthlyRate || 0)
|
: Number(item.monthlyRate || 0)
|
||||||
return acc + convertCurrency(amount, item.currency || 'USD', baseCurrency, ratesData)
|
const cur = effectiveVpsTariffCurrency(item, providerById.get(item.providerId))
|
||||||
|
return acc + convertCurrency(amount, cur, baseCurrency, ratesData)
|
||||||
}, 0)
|
}, 0)
|
||||||
}, [vps, baseCurrency, ratesData])
|
}, [vps, baseCurrency, ratesData, providerById])
|
||||||
|
|
||||||
const monthExpenses = useMemo(() => {
|
const monthExpenses = useMemo(() => {
|
||||||
return [...payments, ...balanceLedger]
|
return [...payments, ...balanceLedger]
|
||||||
@@ -144,14 +150,15 @@ export function DashboardPage({ db = {}, settings, ratesData }) {
|
|||||||
tariffType === 'daily'
|
tariffType === 'daily'
|
||||||
? Number(item.dailyRate || 0) * 30
|
? Number(item.dailyRate || 0) * 30
|
||||||
: Number(item.monthlyRate || 0)
|
: Number(item.monthlyRate || 0)
|
||||||
map[key].forecast += convertCurrency(amount, item.currency || 'USD', baseCurrency, ratesData)
|
const cur = effectiveVpsTariffCurrency(item, providerById.get(item.providerId))
|
||||||
|
map[key].forecast += convertCurrency(amount, cur, baseCurrency, ratesData)
|
||||||
}
|
}
|
||||||
return Object.values(map).sort((a, b) => {
|
return Object.values(map).sort((a, b) => {
|
||||||
if (a.key === '__none__') return 1
|
if (a.key === '__none__') return 1
|
||||||
if (b.key === '__none__') return -1
|
if (b.key === '__none__') return -1
|
||||||
return a.label.localeCompare(b.label, 'ru')
|
return a.label.localeCompare(b.label, 'ru')
|
||||||
})
|
})
|
||||||
}, [vps, baseCurrency, ratesData])
|
}, [vps, baseCurrency, ratesData, providerById])
|
||||||
|
|
||||||
const forecastByAccount = useMemo(() => {
|
const forecastByAccount = useMemo(() => {
|
||||||
return providerAccounts
|
return providerAccounts
|
||||||
@@ -164,13 +171,14 @@ export function DashboardPage({ db = {}, settings, ratesData }) {
|
|||||||
tariffType === 'daily'
|
tariffType === 'daily'
|
||||||
? Number(item.dailyRate || 0) * 30
|
? Number(item.dailyRate || 0) * 30
|
||||||
: Number(item.monthlyRate || 0)
|
: Number(item.monthlyRate || 0)
|
||||||
forecast += convertCurrency(amount, item.currency || 'USD', baseCurrency, ratesData)
|
const cur = effectiveVpsTariffCurrency(item, providerById.get(item.providerId))
|
||||||
|
forecast += convertCurrency(amount, cur, baseCurrency, ratesData)
|
||||||
}
|
}
|
||||||
return { account: acc, count: items.length, forecast }
|
return { account: acc, count: items.length, forecast }
|
||||||
})
|
})
|
||||||
.filter((row) => row.count > 0)
|
.filter((row) => row.count > 0)
|
||||||
.sort((a, b) => b.forecast - a.forecast)
|
.sort((a, b) => b.forecast - a.forecast)
|
||||||
}, [vps, providerAccounts, baseCurrency, ratesData])
|
}, [vps, providerAccounts, baseCurrency, ratesData, providerById])
|
||||||
|
|
||||||
const accountBalances = providerAccounts.map((account) => {
|
const accountBalances = providerAccounts.map((account) => {
|
||||||
if (account.balance_api != null && Number.isFinite(Number(account.balance_api))) {
|
if (account.balance_api != null && Number.isFinite(Number(account.balance_api))) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useMemo, useState } from 'react'
|
|||||||
import {
|
import {
|
||||||
convertCurrency,
|
convertCurrency,
|
||||||
downloadTextFile,
|
downloadTextFile,
|
||||||
|
effectiveVpsTariffCurrency,
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
toCsv,
|
toCsv,
|
||||||
vpsStatusLabel,
|
vpsStatusLabel,
|
||||||
@@ -134,7 +135,7 @@ export function ReportsPage({ db, settings, ratesData }) {
|
|||||||
city: vps.city || '',
|
city: vps.city || '',
|
||||||
status: vps.status,
|
status: vps.status,
|
||||||
expense: Number(total.toFixed(2)),
|
expense: Number(total.toFixed(2)),
|
||||||
currency: vps.currency || 'USD',
|
currency: effectiveVpsTariffCurrency(vps, provider),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [db.payments, db.balanceLedger, db.providers, db.providerAccounts, db.vps, filters])
|
}, [db.payments, db.balanceLedger, db.providers, db.providerAccounts, db.vps, filters])
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
convertCurrency,
|
convertCurrency,
|
||||||
|
effectiveVpsTariffCurrency,
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
} from '../lib/utils'
|
} from '../lib/utils'
|
||||||
import { EmptyState } from '../components/EmptyState'
|
import { EmptyState } from '../components/EmptyState'
|
||||||
import { PageHeader } from '../components/PageHeader'
|
import { PageHeader } from '../components/PageHeader'
|
||||||
import { noBrowserSuggestProps } from '../lib/noBrowserSuggestProps'
|
import { noBrowserSuggestProps } from '../lib/noBrowserSuggestProps'
|
||||||
|
|
||||||
function vpsMonthlyEstimateInBase(item, baseCurrency, ratesData) {
|
function vpsMonthlyEstimateInBase(item, baseCurrency, ratesData, providerById) {
|
||||||
if (item.status !== 'active') return 0
|
if (item.status !== 'active') return 0
|
||||||
const tariffType = item.tariffType || (Number(item.dailyRate || 0) > 0 ? 'daily' : 'monthly')
|
const tariffType = item.tariffType || (Number(item.dailyRate || 0) > 0 ? 'daily' : 'monthly')
|
||||||
const amount =
|
const amount =
|
||||||
tariffType === 'daily'
|
tariffType === 'daily'
|
||||||
? Number(item.dailyRate || 0) * 30
|
? Number(item.dailyRate || 0) * 30
|
||||||
: Number(item.monthlyRate || 0)
|
: Number(item.monthlyRate || 0)
|
||||||
return convertCurrency(amount, item.currency || 'USD', baseCurrency, ratesData)
|
const cur = effectiveVpsTariffCurrency(item, providerById.get(item.providerId))
|
||||||
|
return convertCurrency(amount, cur, baseCurrency, ratesData)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ResourcesPage({ db, settings, ratesData }) {
|
export function ResourcesPage({ db, settings, ratesData }) {
|
||||||
@@ -74,6 +76,11 @@ export function ResourcesPage({ db, settings, ratesData }) {
|
|||||||
})
|
})
|
||||||
}, [db.vps, filters, customFields])
|
}, [db.vps, filters, customFields])
|
||||||
|
|
||||||
|
const providerById = useMemo(
|
||||||
|
() => new Map((db.providers || []).map((p) => [p.id, p])),
|
||||||
|
[db.providers],
|
||||||
|
)
|
||||||
|
|
||||||
const groups = useMemo(() => {
|
const groups = useMemo(() => {
|
||||||
const map = new Map()
|
const map = new Map()
|
||||||
const accounts = db.providerAccounts || []
|
const accounts = db.providerAccounts || []
|
||||||
@@ -123,7 +130,7 @@ export function ResourcesPage({ db, settings, ratesData }) {
|
|||||||
g.vcpu += Number(item.vcpu || 0)
|
g.vcpu += Number(item.vcpu || 0)
|
||||||
g.ramGb += Number(item.ramGb || 0)
|
g.ramGb += Number(item.ramGb || 0)
|
||||||
g.diskGb += Number(item.diskGb || 0)
|
g.diskGb += Number(item.diskGb || 0)
|
||||||
g.forecast += vpsMonthlyEstimateInBase(item, baseCurrency, ratesData)
|
g.forecast += vpsMonthlyEstimateInBase(item, baseCurrency, ratesData, providerById)
|
||||||
}
|
}
|
||||||
const list = [...map.values()]
|
const list = [...map.values()]
|
||||||
list.sort((a, b) => {
|
list.sort((a, b) => {
|
||||||
@@ -134,7 +141,7 @@ export function ResourcesPage({ db, settings, ratesData }) {
|
|||||||
return b.forecast - a.forecast || b.vcpu - a.vcpu
|
return b.forecast - a.forecast || b.vcpu - a.vcpu
|
||||||
})
|
})
|
||||||
return list
|
return list
|
||||||
}, [vpsFiltered, groupBy, db.providerAccounts, baseCurrency, ratesData])
|
}, [vpsFiltered, groupBy, db.providerAccounts, baseCurrency, ratesData, providerById])
|
||||||
|
|
||||||
const maxForecast = useMemo(
|
const maxForecast = useMemo(
|
||||||
() => groups.reduce((m, g) => Math.max(m, g.forecast), 0),
|
() => groups.reduce((m, g) => Math.max(m, g.forecast), 0),
|
||||||
|
|||||||
+13
-30
@@ -374,14 +374,10 @@ export function VpsPage({ db, actions, settings, ratesData }) {
|
|||||||
customFields.forEach((f) => {
|
customFields.forEach((f) => {
|
||||||
customFieldValues[f.key] = form[f.key] ?? ''
|
customFieldValues[f.key] = form[f.key] ?? ''
|
||||||
})
|
})
|
||||||
const prov = providerById.get(form.providerId)
|
|
||||||
const resolvedCurrency =
|
|
||||||
(form.currency || '').trim() || (prov?.baseCurrency || '').trim() || 'USD'
|
|
||||||
const payload = {
|
const payload = {
|
||||||
...restForm,
|
...restForm,
|
||||||
...customFieldValues,
|
...customFieldValues,
|
||||||
additionalIps,
|
additionalIps,
|
||||||
currency: resolvedCurrency,
|
|
||||||
dailyRate: form.tariffType === 'daily' ? form.dailyRate : '',
|
dailyRate: form.tariffType === 'daily' ? form.dailyRate : '',
|
||||||
monthlyRate: form.tariffType === 'monthly' ? form.monthlyRate : '',
|
monthlyRate: form.tariffType === 'monthly' ? form.monthlyRate : '',
|
||||||
}
|
}
|
||||||
@@ -426,7 +422,7 @@ export function VpsPage({ db, actions, settings, ratesData }) {
|
|||||||
status: vps.status || 'active',
|
status: vps.status || 'active',
|
||||||
tariffType:
|
tariffType:
|
||||||
vps.tariffType || (Number(vps.dailyRate || 0) > 0 ? 'daily' : 'monthly'),
|
vps.tariffType || (Number(vps.dailyRate || 0) > 0 ? 'daily' : 'monthly'),
|
||||||
currency: effectiveVpsTariffCurrency(vps, providerById.get(vps.providerId)),
|
currency: vps.currency || '',
|
||||||
dailyRate: vps.dailyRate || '',
|
dailyRate: vps.dailyRate || '',
|
||||||
monthlyRate: vps.monthlyRate || '',
|
monthlyRate: vps.monthlyRate || '',
|
||||||
createdAt: vps.createdAt || '',
|
createdAt: vps.createdAt || '',
|
||||||
@@ -1440,18 +1436,13 @@ export function VpsPage({ db, actions, settings, ratesData }) {
|
|||||||
<select autoComplete="off"
|
<select autoComplete="off"
|
||||||
className="form-select"
|
className="form-select"
|
||||||
value={form.providerId}
|
value={form.providerId}
|
||||||
onChange={(e) => {
|
onChange={(e) =>
|
||||||
const newProviderId = e.target.value
|
|
||||||
const prov = providerById.get(newProviderId)
|
|
||||||
const nextCur =
|
|
||||||
(prov?.baseCurrency || '').trim().toUpperCase() || 'USD'
|
|
||||||
setForm((prev) => ({
|
setForm((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
providerId: newProviderId,
|
providerId: e.target.value,
|
||||||
providerAccountId: '',
|
providerAccountId: '',
|
||||||
currency: nextCur,
|
|
||||||
}))
|
}))
|
||||||
}}
|
}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="">— Выберите —</option>
|
<option value="">— Выберите —</option>
|
||||||
@@ -1681,7 +1672,7 @@ export function VpsPage({ db, actions, settings, ratesData }) {
|
|||||||
<section className="vps-form-section">
|
<section className="vps-form-section">
|
||||||
<h6 className="vps-form-section-title">Тариф и оплата</h6>
|
<h6 className="vps-form-section-title">Тариф и оплата</h6>
|
||||||
<div className="row g-3">
|
<div className="row g-3">
|
||||||
<div className="col-12 col-sm-6 col-md-3">
|
<div className="col-12 col-sm-6 col-md-4">
|
||||||
<label className="form-label">Статус</label>
|
<label className="form-label">Статус</label>
|
||||||
<select autoComplete="off"
|
<select autoComplete="off"
|
||||||
className="form-select"
|
className="form-select"
|
||||||
@@ -1693,7 +1684,7 @@ export function VpsPage({ db, actions, settings, ratesData }) {
|
|||||||
<option value="archived">{vpsStatusLabel('archived')}</option>
|
<option value="archived">{vpsStatusLabel('archived')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-12 col-sm-6 col-md-3">
|
<div className="col-12 col-sm-6 col-md-4">
|
||||||
<label className="form-label">Тип тарифа</label>
|
<label className="form-label">Тип тарифа</label>
|
||||||
<select autoComplete="off"
|
<select autoComplete="off"
|
||||||
className="form-select"
|
className="form-select"
|
||||||
@@ -1711,7 +1702,7 @@ export function VpsPage({ db, actions, settings, ratesData }) {
|
|||||||
<option value="monthly">{tariffTypeLabel('monthly')}</option>
|
<option value="monthly">{tariffTypeLabel('monthly')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-12 col-sm-6 col-md-3">
|
<div className="col-12 col-sm-6 col-md-4">
|
||||||
<label className="form-label">
|
<label className="form-label">
|
||||||
{form.tariffType === 'daily' ? 'Суточный тариф' : 'Месячный тариф'}
|
{form.tariffType === 'daily' ? 'Суточный тариф' : 'Месячный тариф'}
|
||||||
</label>
|
</label>
|
||||||
@@ -1731,21 +1722,13 @@ export function VpsPage({ db, actions, settings, ratesData }) {
|
|||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-12 col-sm-6 col-md-3">
|
{form.providerId ? (
|
||||||
<label className="form-label">Валюта тарифа</label>
|
<div className="col-12">
|
||||||
<select autoComplete="off"
|
<span className="text-secondary small">
|
||||||
className="form-select"
|
Валюта тарифа: {(providerById.get(form.providerId)?.baseCurrency || '—').toUpperCase()} (из справочника хостера)
|
||||||
value={form.currency || (providerById.get(form.providerId)?.baseCurrency || '').trim() || 'USD'}
|
</span>
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, currency: e.target.value }))}
|
|
||||||
>
|
|
||||||
<option value="RUB">RUB</option>
|
|
||||||
<option value="USD">USD</option>
|
|
||||||
<option value="EUR">EUR</option>
|
|
||||||
</select>
|
|
||||||
<div className="text-secondary small mt-1">
|
|
||||||
В списке VPS и конвертации используется эта валюта, а не только валюта хостера в справочнике.
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
<div className="col-12 col-sm-6">
|
<div className="col-12 col-sm-6">
|
||||||
<label className="form-label">Дата создания</label>
|
<label className="form-label">Дата создания</label>
|
||||||
<input {...noBrowserSuggestProps}
|
<input {...noBrowserSuggestProps}
|
||||||
|
|||||||
Reference in New Issue
Block a user