feat: расчёт стоимости платного тестирования/аренды GPU-серверов
Новая вкладка «Аренда» в конфигураторе (доступна для проектов с флагом
rental_enabled): расчёт по методичке "Регламент расчёта стоимости
платного тестирования и аренды GPU-серверов" — Разовый + Еженедельный
платёж по каждому компоненту (New/БУ чекбоксом), с аплифтом к Estimate.
Заодно: поддержка (support_code) в Base-вкладке теперь добавляется как
обычный LOT в спеку (SVC_{срок}y{уровень}_{платформа}) через
autocomplete-пикер вместо выпадающего списка — партномер собирается
тем же lot_name-based механизмом, что и для GPU/CPU/памяти, справочная
цена по регламенту техподдержки хранится в qt_settings.support_pricing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
b48436de93
commit
7edb80e498
+381
-13
@@ -78,6 +78,10 @@
|
||||
class="px-5 py-3 text-sm font-semibold border-b-2 border-transparent text-gray-500 hover:text-gray-700">
|
||||
Ценообразование
|
||||
</button>
|
||||
<button id="top-tab-rental" onclick="switchTopTab('rental')"
|
||||
class="px-5 py-3 text-sm font-semibold border-b-2 border-transparent text-gray-500 hover:text-gray-700 hidden">
|
||||
Аренда
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -305,6 +309,54 @@
|
||||
|
||||
</div><!-- end top-section-pricing -->
|
||||
|
||||
<!-- Top-tab section: Аренда (paid testing / rental) -->
|
||||
<div id="top-section-rental" class="hidden space-y-6">
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<div class="flex items-baseline gap-3 mb-1">
|
||||
<h3 class="text-base font-semibold text-gray-800">Расчёт стоимости платного тестирования / аренды</h3>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mb-3">Черновая методика — параметры и коэффициенты могут измениться. Цена всегда за 1 неделю: разовый платёж (один раз) + еженедельный платёж (за каждую неделю теста/аренды). Цена берётся из Estimate, увеличенного на аплифт ниже. Поддержка сюда не входит.</p>
|
||||
<div class="flex flex-wrap items-end gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Аплифт к Estimate, %</label>
|
||||
<input type="number" id="rental-uplift-percent" min="0" step="0.1" value="0"
|
||||
class="w-28 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||||
oninput="scheduleRentalRecalc()" onchange="scheduleRentalRecalc()">
|
||||
</div>
|
||||
<button onclick="saveRentalSettings()" class="px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm">
|
||||
Сохранить
|
||||
</button>
|
||||
<span id="rental-save-status" class="text-xs text-gray-500"></span>
|
||||
</div>
|
||||
<div id="rental-warnings" class="hidden text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded px-3 py-2 mb-3"></div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm border-collapse">
|
||||
<thead class="bg-gray-50 text-gray-700">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left border-b">LOT</th>
|
||||
<th class="px-3 py-2 text-left border-b">Описание</th>
|
||||
<th class="px-3 py-2 text-left border-b">Категория</th>
|
||||
<th class="px-3 py-2 text-right border-b">Кол-во</th>
|
||||
<th class="px-3 py-2 text-center border-b">БУ</th>
|
||||
<th class="px-3 py-2 text-right border-b">Разовый платёж</th>
|
||||
<th class="px-3 py-2 text-right border-b">Еженедельный платёж</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="rental-body">
|
||||
<tr><td colspan="7" class="px-3 py-8 text-center text-gray-400">Загрузите компоненты во вкладке «Estimate»</td></tr>
|
||||
</tbody>
|
||||
<tfoot id="rental-foot" class="hidden bg-gray-50 font-semibold">
|
||||
<tr>
|
||||
<td colspan="5" class="px-3 py-2 text-right">Итого (с НДС):</td>
|
||||
<td class="px-3 py-2 text-right" id="rental-total-onetime">—</td>
|
||||
<td class="px-3 py-2 text-right" id="rental-total-weekly">—</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- end top-section-rental -->
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Price settings modal -->
|
||||
@@ -842,9 +894,15 @@ function applyServerSettings(settings) {
|
||||
});
|
||||
}
|
||||
|
||||
// support_pricing → picker price table
|
||||
if (settings.support_pricing && typeof settings.support_pricing === 'object') {
|
||||
supportPricingTable = settings.support_pricing;
|
||||
}
|
||||
|
||||
applyConfigTypeToTabs();
|
||||
updateTabVisibility();
|
||||
updateRequiredCategoryBadges();
|
||||
updateSupportPriceDisplay();
|
||||
}
|
||||
|
||||
function updateRequiredCategoryBadges() {
|
||||
@@ -904,6 +962,14 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
projectUUID = config.project_uuid || '';
|
||||
await loadProjectIndex();
|
||||
updateConfigBreadcrumbs();
|
||||
applyRentalTabVisibility();
|
||||
|
||||
rentalConditions = {};
|
||||
(config.rental_items || []).forEach(item => {
|
||||
rentalConditions[(item.lot_name || '').toUpperCase()] = item.condition === 'used' ? 'used' : 'new';
|
||||
});
|
||||
rentalUpliftPercent = config.rental_uplift_percent || 0;
|
||||
document.getElementById('rental-uplift-percent').value = rentalUpliftPercent;
|
||||
document.getElementById('save-buttons').classList.remove('hidden');
|
||||
|
||||
// Set server count from config
|
||||
@@ -928,7 +994,7 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
category: item.category }));
|
||||
}
|
||||
serverModelForQuote = config.server_model || '';
|
||||
supportCode = config.support_code || '';
|
||||
supportCode = parseSupportCodeFromLotName((cart.find(i => (i.lot_name || '').toUpperCase().startsWith('SVC_')) || {}).lot_name);
|
||||
currentArticle = config.article || '';
|
||||
restorePricingStateFromNotes(config.notes || '');
|
||||
|
||||
@@ -972,6 +1038,8 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!e.target.closest('.autocomplete-wrapper')) {
|
||||
hideAutocomplete();
|
||||
const supportDropdown = document.getElementById('support-code-dropdown');
|
||||
if (supportDropdown) supportDropdown.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1381,23 +1449,29 @@ function renderSingleSelectTab(categories) {
|
||||
html += `
|
||||
<div class="mb-1 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
|
||||
<label for="server-model-input" class="block text-sm font-medium text-gray-700">Модель системы для партномера:</label>
|
||||
<label for="support-code-select" class="block text-sm font-medium text-gray-700">Уровень техподдержки:</label>
|
||||
<label for="support-code-input" class="block text-sm font-medium text-gray-700">Уровень техподдержки:</label>
|
||||
</div>
|
||||
<div class="mb-3 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
|
||||
<div class="mb-1 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
|
||||
<input type="text"
|
||||
id="server-model-input"
|
||||
value="${escapeHtml(serverModelForQuote)}"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||||
oninput="updateServerModelForQuote(this.value)">
|
||||
<select id="support-code-select"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||||
onchange="updateSupportCode(this.value)">
|
||||
<option value="">—</option>
|
||||
<option value="1yW" ${supportCode === '1yW' ? 'selected' : ''}>1yW</option>
|
||||
<option value="1yB" ${supportCode === '1yB' ? 'selected' : ''}>1yB</option>
|
||||
<option value="1yS" ${supportCode === '1yS' ? 'selected' : ''}>1yS</option>
|
||||
<option value="1yP" ${supportCode === '1yP' ? 'selected' : ''}>1yP</option>
|
||||
</select>
|
||||
<div class="autocomplete-wrapper relative">
|
||||
<input type="text"
|
||||
id="support-code-input"
|
||||
autocomplete="off"
|
||||
placeholder="Начните вводить..."
|
||||
value="${escapeHtml(supportCodeLabel(supportCode))}"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||||
oninput="onSupportCodeInput(this.value)"
|
||||
onfocus="onSupportCodeInput(this.value)">
|
||||
<div id="support-code-dropdown" class="hidden absolute z-50 bg-white border rounded-lg shadow-lg max-h-72 overflow-y-auto w-full mt-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
|
||||
<div></div>
|
||||
<div id="support-code-price" class="text-xs text-gray-500"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -2249,6 +2323,7 @@ function removeFromCart(lotName) {
|
||||
function updateCartUI() {
|
||||
updateTabVisibility();
|
||||
updateRequiredCategoryBadges();
|
||||
updateSupportPriceDisplay();
|
||||
window._currentCart = cart; // expose for BOM/Pricing tabs
|
||||
const total = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0);
|
||||
document.getElementById('cart-total').textContent = formatMoney(total);
|
||||
@@ -2343,6 +2418,160 @@ function updateSupportCode(value) {
|
||||
scheduleArticlePreview();
|
||||
}
|
||||
|
||||
// ==================== SUPPORT PICKER (Регламент расчёта стоимости техподдержки) ====================
|
||||
// Support is added to the spec as a regular LOT in `cart` (lot_name like "SVC_3yB_HGX-H200"),
|
||||
// exactly like any other component — the article generator picks it up via the same
|
||||
// lot_name-pattern segment mechanism used for GPU/CPU/etc (internal/article/generator.go), no
|
||||
// separate support field/mechanism. Price data (supportPricingTable) is server-driven via
|
||||
// qt_settings.support_pricing so it can be edited in MariaDB without a release; the list of
|
||||
// offerable level/duration codes is fixed here.
|
||||
|
||||
let supportPricingTable = null;
|
||||
|
||||
const SUPPORT_LEVEL_NAMES = { W: 'Warranty', B: 'BASE', S: 'STANDARD', P: 'PREMIUM' };
|
||||
|
||||
// All (duration, level) combinations the regulation offers, per platform.
|
||||
// HGX Warranty is included by default (no purchasable code), so it's x86-only here.
|
||||
const SUPPORT_CODE_COMBOS = {
|
||||
x86: ['3yW', '1yB', '3yB', '5yB', '1yS', '3yS', '5yS', '1yP', '3yP', '5yP'],
|
||||
HGX: ['1yB', '3yB', '5yB', '1yS', '3yS', '5yS', '1yP', '3yP']
|
||||
};
|
||||
|
||||
function supportCodeLabel(code) {
|
||||
if (!code) return '';
|
||||
const parts = code.split('y');
|
||||
if (parts.length !== 2) return code;
|
||||
const years = parts[0];
|
||||
const levelName = SUPPORT_LEVEL_NAMES[parts[1]] || parts[1];
|
||||
return years + ' ' + (years === '1' ? 'год' : 'года') + ' — ' + levelName + ' (' + code + ')';
|
||||
}
|
||||
|
||||
// Same "SVC_{years}y{level}_{platform}" shape as internal/article/generator.go's buildSupportSegment.
|
||||
function parseSupportCodeFromLotName(lotName) {
|
||||
if (!lotName) return '';
|
||||
const parts = lotName.split('_');
|
||||
return parts.length >= 2 ? parts[1] : '';
|
||||
}
|
||||
|
||||
function findSupportCartIndex() {
|
||||
return cart.findIndex(i => (i.lot_name || '').toUpperCase().startsWith('SVC_'));
|
||||
}
|
||||
|
||||
// Mirrors the GPU chip-generation classification used by internal/services/rental.go,
|
||||
// so the support picker and the rental calc agree on which platform a config is.
|
||||
function detectSupportPlatform() {
|
||||
const actualGenSubstrings = ['H200', 'B200', 'B300', 'BLACKWELL SE'];
|
||||
let sawGPU = false;
|
||||
for (const item of cart) {
|
||||
if ((item.category || '').toUpperCase() !== 'GPU') continue;
|
||||
sawGPU = true;
|
||||
const upperLot = (item.lot_name || '').toUpperCase();
|
||||
if (upperLot.includes('B300')) return 'HGX-B300';
|
||||
if (upperLot.includes('B200')) return 'HGX-B200';
|
||||
}
|
||||
return sawGPU ? 'HGX-H200' : 'x86';
|
||||
}
|
||||
|
||||
// cartTotal excludes any existing support line so the x86 percent is based on hardware only.
|
||||
function computeSupportPrice(code, platform) {
|
||||
if (!supportPricingTable) return null;
|
||||
const parts = code.split('y');
|
||||
if (parts.length !== 2) return null;
|
||||
const years = parts[0];
|
||||
const level = parts[1];
|
||||
|
||||
if (platform === 'x86') {
|
||||
const percent = supportPricingTable.x86_percent && supportPricingTable.x86_percent[level] && supportPricingTable.x86_percent[level][years];
|
||||
if (typeof percent !== 'number') return null;
|
||||
const cartTotal = cart.reduce((sum, item) => {
|
||||
if ((item.lot_name || '').toUpperCase().startsWith('SVC_')) return sum;
|
||||
return sum + (getDisplayPrice(item) * item.quantity);
|
||||
}, 0);
|
||||
return cartTotal * percent;
|
||||
}
|
||||
const price = supportPricingTable.hgx_price && supportPricingTable.hgx_price[platform] && supportPricingTable.hgx_price[platform][level] && supportPricingTable.hgx_price[platform][level][years];
|
||||
return typeof price === 'number' ? price : null;
|
||||
}
|
||||
|
||||
// Re-syncs supportCode/input from cart so removing the SVC_ line via the normal
|
||||
// per-item remove button (like any other component) also clears the picker.
|
||||
function updateSupportPriceDisplay() {
|
||||
supportCode = parseSupportCodeFromLotName((cart.find(i => (i.lot_name || '').toUpperCase().startsWith('SVC_')) || {}).lot_name);
|
||||
const input = document.getElementById('support-code-input');
|
||||
if (input && document.activeElement !== input) {
|
||||
input.value = supportCodeLabel(supportCode);
|
||||
}
|
||||
|
||||
const el = document.getElementById('support-code-price');
|
||||
if (!el) return;
|
||||
if (!supportCode) {
|
||||
el.textContent = '';
|
||||
return;
|
||||
}
|
||||
const platform = detectSupportPlatform();
|
||||
const price = computeSupportPrice(supportCode, platform);
|
||||
if (price === null) {
|
||||
el.textContent = 'Нет цены для этой комбинации (' + platform + ')';
|
||||
return;
|
||||
}
|
||||
el.textContent = formatMoney(price) + ' за весь срок (' + platform + ')';
|
||||
}
|
||||
|
||||
function onSupportCodeInput(text) {
|
||||
const platform = detectSupportPlatform();
|
||||
const combos = platform === 'x86' ? SUPPORT_CODE_COMBOS.x86 : SUPPORT_CODE_COMBOS.HGX;
|
||||
const query = (text || '').trim().toLowerCase();
|
||||
const matches = combos.filter(code => !query || supportCodeLabel(code).toLowerCase().includes(query) || code.toLowerCase().includes(query));
|
||||
renderSupportCodeDropdown(matches);
|
||||
}
|
||||
|
||||
function renderSupportCodeDropdown(codes) {
|
||||
const dropdown = document.getElementById('support-code-dropdown');
|
||||
if (!dropdown) return;
|
||||
let html = '<div class="autocomplete-item px-3 py-2 hover:bg-gray-100 cursor-pointer text-gray-500" onclick="selectSupportCode(\'\')">— без поддержки —</div>';
|
||||
html += codes.map(code =>
|
||||
'<div class="autocomplete-item px-3 py-2 hover:bg-gray-100 cursor-pointer" onclick="selectSupportCode(\'' + code + '\')">' +
|
||||
escapeHtml(supportCodeLabel(code)) +
|
||||
'</div>'
|
||||
).join('');
|
||||
dropdown.innerHTML = html;
|
||||
dropdown.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Adds/replaces/removes the support LOT in `cart`, exactly like adding/removing any other
|
||||
// component — this is what the article generator, totals, and everything else sees.
|
||||
function selectSupportCode(code) {
|
||||
const existingIdx = findSupportCartIndex();
|
||||
if (existingIdx >= 0) {
|
||||
cart.splice(existingIdx, 1);
|
||||
}
|
||||
|
||||
supportCode = code || '';
|
||||
if (supportCode) {
|
||||
const platform = detectSupportPlatform();
|
||||
const price = computeSupportPrice(supportCode, platform);
|
||||
cart.push({
|
||||
lot_name: 'SVC_' + supportCode + '_' + platform,
|
||||
quantity: 1,
|
||||
unit_price: price || 0,
|
||||
estimate_price: price || 0,
|
||||
warehouse_price: null,
|
||||
competitor_price: null,
|
||||
description: supportCodeLabel(supportCode),
|
||||
category: ''
|
||||
});
|
||||
}
|
||||
|
||||
const input = document.getElementById('support-code-input');
|
||||
if (input) input.value = supportCodeLabel(supportCode);
|
||||
const dropdown = document.getElementById('support-code-dropdown');
|
||||
if (dropdown) dropdown.classList.add('hidden');
|
||||
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
triggerAutoSave();
|
||||
}
|
||||
|
||||
function scheduleArticlePreview() {
|
||||
if (articlePreviewTimeout) {
|
||||
clearTimeout(articlePreviewTimeout);
|
||||
@@ -3059,7 +3288,7 @@ let currentTopTab = 'estimate';
|
||||
|
||||
function switchTopTab(tab) {
|
||||
currentTopTab = tab;
|
||||
const tabs = ['estimate', 'bom', 'pricing'];
|
||||
const tabs = ['estimate', 'bom', 'pricing', 'rental'];
|
||||
tabs.forEach(t => {
|
||||
const btn = document.getElementById('top-tab-' + t);
|
||||
const section = document.getElementById('top-section-' + t);
|
||||
@@ -3076,6 +3305,145 @@ function switchTopTab(tab) {
|
||||
if (tab === 'pricing') {
|
||||
renderPricingTab();
|
||||
}
|
||||
if (tab === 'rental') {
|
||||
renderRentalTab();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== АРЕНДА (rental / paid testing) ====================
|
||||
|
||||
let rentalConditions = {}; // lot_name (upper) -> 'new'|'used'
|
||||
let rentalUpliftPercent = 0;
|
||||
let rentalRecalcTimer = null;
|
||||
|
||||
function applyRentalTabVisibility() {
|
||||
const enabled = !!(projectUUID && projectByUUID[projectUUID] && projectByUUID[projectUUID].rental_enabled);
|
||||
const btn = document.getElementById('top-tab-rental');
|
||||
if (!btn) return;
|
||||
if (enabled) {
|
||||
btn.classList.remove('hidden');
|
||||
} else {
|
||||
btn.classList.add('hidden');
|
||||
if (currentTopTab === 'rental') {
|
||||
switchTopTab('estimate');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRentalRecalc() {
|
||||
clearTimeout(rentalRecalcTimer);
|
||||
rentalRecalcTimer = setTimeout(renderRentalTab, 300);
|
||||
}
|
||||
|
||||
function rentalConditionFor(lotName) {
|
||||
const key = (lotName || '').toUpperCase();
|
||||
return rentalConditions[key] === 'used' ? 'used' : 'new';
|
||||
}
|
||||
|
||||
function onRentalConditionChange(lotName, isUsed) {
|
||||
const key = (lotName || '').toUpperCase();
|
||||
rentalConditions[key] = isUsed ? 'used' : 'new';
|
||||
renderRentalTab();
|
||||
}
|
||||
|
||||
function buildRentalItemsPayload() {
|
||||
return cart.map(item => ({
|
||||
lot_name: item.lot_name,
|
||||
condition: rentalConditionFor(item.lot_name)
|
||||
}));
|
||||
}
|
||||
|
||||
async function renderRentalTab() {
|
||||
const body = document.getElementById('rental-body');
|
||||
const foot = document.getElementById('rental-foot');
|
||||
const warningsEl = document.getElementById('rental-warnings');
|
||||
if (!configUUID || cart.length === 0) {
|
||||
body.innerHTML = '<tr><td colspan="7" class="px-3 py-8 text-center text-gray-400">Загрузите компоненты во вкладке «Estimate»</td></tr>';
|
||||
foot.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
rentalUpliftPercent = parseFloat(document.getElementById('rental-uplift-percent').value) || 0;
|
||||
|
||||
const payload = {
|
||||
items: buildRentalItemsPayload(),
|
||||
uplift_percent: rentalUpliftPercent
|
||||
};
|
||||
|
||||
let result;
|
||||
try {
|
||||
const resp = await fetch('/api/configs/' + configUUID + '/rental/calculate', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!resp.ok) {
|
||||
body.innerHTML = '<tr><td colspan="7" class="px-3 py-8 text-center text-red-500">Не удалось рассчитать стоимость аренды</td></tr>';
|
||||
foot.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
result = await resp.json();
|
||||
} catch (e) {
|
||||
body.innerHTML = '<tr><td colspan="7" class="px-3 py-8 text-center text-red-500">Не удалось рассчитать стоимость аренды</td></tr>';
|
||||
foot.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.warnings && result.warnings.length > 0) {
|
||||
warningsEl.textContent = result.warnings.join(' ');
|
||||
warningsEl.classList.remove('hidden');
|
||||
} else {
|
||||
warningsEl.classList.add('hidden');
|
||||
}
|
||||
|
||||
const descByLot = {};
|
||||
cart.forEach(c => { descByLot[(c.lot_name || '').toUpperCase()] = c.description || ''; });
|
||||
|
||||
const rows = (result.items || []).map(item => {
|
||||
const isUsed = item.condition === 'used';
|
||||
const desc = descByLot[(item.lot_name || '').toUpperCase()] || '';
|
||||
return '<tr class="border-b">' +
|
||||
'<td class="px-3 py-2">' + escapeHtml(item.lot_name) + '</td>' +
|
||||
'<td class="px-3 py-2 text-gray-500">' + escapeHtml(desc) + '</td>' +
|
||||
'<td class="px-3 py-2 text-gray-500">' + escapeHtml(item.category || '') + '</td>' +
|
||||
'<td class="px-3 py-2 text-right">' + item.quantity + '</td>' +
|
||||
'<td class="px-3 py-2 text-center">' +
|
||||
'<input type="checkbox" ' + (isUsed ? 'checked' : '') +
|
||||
' onchange="onRentalConditionChange(\'' + escapeHtml(item.lot_name).replace(/'/g, "\\'") + '\', this.checked)" class="rounded border-gray-300">' +
|
||||
'</td>' +
|
||||
'<td class="px-3 py-2 text-right">' + formatMoney(item.one_time) + '</td>' +
|
||||
'<td class="px-3 py-2 text-right">' + formatMoney(item.weekly) + '</td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
body.innerHTML = rows || '<tr><td colspan="7" class="px-3 py-8 text-center text-gray-400">Нет компонентов</td></tr>';
|
||||
|
||||
document.getElementById('rental-total-onetime').textContent = formatMoney(result.one_time_total);
|
||||
document.getElementById('rental-total-weekly').textContent = formatMoney(result.weekly_total);
|
||||
foot.classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function saveRentalSettings() {
|
||||
if (!configUUID) return;
|
||||
const statusEl = document.getElementById('rental-save-status');
|
||||
const payload = {
|
||||
items: buildRentalItemsPayload(),
|
||||
uplift_percent: rentalUpliftPercent
|
||||
};
|
||||
try {
|
||||
const resp = await fetch('/api/configs/' + configUUID + '/rental', {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!resp.ok) {
|
||||
statusEl.textContent = 'Ошибка сохранения';
|
||||
return;
|
||||
}
|
||||
statusEl.textContent = 'Сохранено';
|
||||
setTimeout(() => { statusEl.textContent = ''; }, 2000);
|
||||
} catch (e) {
|
||||
statusEl.textContent = 'Ошибка сохранения';
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== BOM ВЕНДОРА ====================
|
||||
|
||||
Reference in New Issue
Block a user