Implement persistent Line ordering for project specs and update bible
This commit is contained in:
@@ -211,6 +211,8 @@ const projectUUID = '{{.ProjectUUID}}';
|
||||
let configStatusMode = 'active';
|
||||
let project = null;
|
||||
let allConfigs = [];
|
||||
let dragConfigUUID = '';
|
||||
let isReorderingConfigs = false;
|
||||
let projectVariants = [];
|
||||
let projectsCatalog = [];
|
||||
let variantMenuInitialized = false;
|
||||
@@ -221,6 +223,11 @@ function escapeHtml(text) {
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function formatMoneyNoDecimals(value) {
|
||||
const safe = Number.isFinite(Number(value)) ? Number(value) : 0;
|
||||
return '$' + Math.round(safe).toLocaleString('en-US');
|
||||
}
|
||||
|
||||
function resolveProjectTrackerURL(projectData) {
|
||||
if (!projectData) return '';
|
||||
const explicitURL = (projectData.tracker_url || '').trim();
|
||||
@@ -350,42 +357,53 @@ function renderConfigs(configs) {
|
||||
let totalSum = 0;
|
||||
let html = '<div class="bg-white rounded-lg shadow overflow-hidden"><table class="w-full">';
|
||||
html += '<thead class="bg-gray-50"><tr>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Line</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">модель</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Название</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Цена (за 1 шт)</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Цена за 1 шт.</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Кол-во</th>';
|
||||
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Сумма</th>';
|
||||
html += '<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">Ревизия</th>';
|
||||
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Стоимость</th>';
|
||||
html += '<th class="px-2 py-3 text-center text-xs font-medium text-gray-500 uppercase w-12"></th>';
|
||||
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
|
||||
html += '</tr></thead><tbody class="divide-y">';
|
||||
html += '</tr></thead><tbody class="divide-y" id="project-configs-tbody">';
|
||||
|
||||
configs.forEach(c => {
|
||||
const date = new Date(c.created_at).toLocaleDateString('ru-RU');
|
||||
configs.forEach((c, idx) => {
|
||||
const total = c.total_price || 0;
|
||||
const serverCount = c.server_count || 1;
|
||||
const author = c.owner_username || (c.user && c.user.username) || '—';
|
||||
const unitPrice = serverCount > 0 ? (total / serverCount) : 0;
|
||||
const lineValue = (typeof c.line === 'number' && c.line > 0) ? c.line : ((idx + 1) * 10);
|
||||
const serverModel = (c.server_model || '').trim() || '—';
|
||||
totalSum += total;
|
||||
|
||||
html += '<tr class="hover:bg-gray-50">';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + date + '</td>';
|
||||
const draggable = configStatusMode === 'active' ? 'true' : 'false';
|
||||
html += '<tr class="hover:bg-gray-50" draggable="' + draggable + '" data-config-uuid="' + c.uuid + '" ondragstart="onConfigDragStart(event)" ondragover="onConfigDragOver(event)" ondragleave="onConfigDragLeave(event)" ondrop="onConfigDrop(event)" ondragend="onConfigDragEnd(event)">';
|
||||
if (configStatusMode === 'active') {
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">';
|
||||
html += '<span class="inline-flex items-center gap-2"><span class="drag-handle text-gray-400 hover:text-gray-700 cursor-grab active:cursor-grabbing select-none" title="Перетащить для изменения порядка" aria-label="Перетащить">';
|
||||
html += '<svg class="w-4 h-4 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 6h.01M8 12h.01M8 18h.01M16 6h.01M16 12h.01M16 18h.01"></path></svg>';
|
||||
html += '</span><span>' + lineValue + '</span></span></td>';
|
||||
} else {
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + lineValue + '</td>';
|
||||
}
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(serverModel) + '</td>';
|
||||
if (configStatusMode === 'archived') {
|
||||
html += '<td class="px-4 py-3 text-sm font-medium text-gray-700">' + escapeHtml(c.name) + '</td>';
|
||||
} else {
|
||||
html += '<td class="px-4 py-3 text-sm font-medium"><a href="/configurator?uuid=' + c.uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(c.name) + '</a></td>';
|
||||
}
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(author) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">$' + unitPrice.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + formatMoneyNoDecimals(unitPrice) + '</td>';
|
||||
if (configStatusMode === 'archived') {
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + serverCount + '</td>';
|
||||
} else {
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500"><input type="number" min="1" value="' + serverCount + '" class="w-16 px-1 py-0.5 border rounded text-center text-sm" data-uuid="' + c.uuid + '" data-prev="' + serverCount + '" onchange="updateConfigServerCount(this)"></td>';
|
||||
}
|
||||
html += '<td class="px-4 py-3 text-sm text-right" data-total-uuid="' + c.uuid + '">$' + total.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-right" data-total-uuid="' + c.uuid + '">' + formatMoneyNoDecimals(total) + '</td>';
|
||||
const versionNo = c.current_version_no || 1;
|
||||
html += '<td class="px-4 py-3 text-sm text-center text-gray-500">v' + versionNo + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-right space-x-2">';
|
||||
html += '<td class="px-2 py-3 text-sm text-center text-gray-500 w-12">v' + versionNo + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-right whitespace-nowrap"><div class="inline-flex items-center justify-end gap-2">';
|
||||
if (configStatusMode === 'archived') {
|
||||
html += '<button onclick="reactivateConfig(\'' + c.uuid + '\')" class="text-emerald-600 hover:text-emerald-800" title="Восстановить">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg></button>';
|
||||
@@ -397,15 +415,16 @@ function renderConfigs(configs) {
|
||||
html += '<button onclick="deleteConfig(\'' + c.uuid + '\')" class="text-red-600 hover:text-red-800" title="В архив">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg></button>';
|
||||
}
|
||||
html += '</td></tr>';
|
||||
html += '</div></td></tr>';
|
||||
});
|
||||
|
||||
html += '</tbody>';
|
||||
html += '<tfoot class="bg-gray-50 border-t">';
|
||||
html += '<tr>';
|
||||
html += '<td class="px-4 py-3 text-sm font-medium text-gray-700" colspan="4">Итого по проекту</td>';
|
||||
html += '<td class="px-4 py-3 text-sm font-medium text-gray-700" colspan="5">Итого по проекту</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-700">' + configs.length + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-right font-semibold text-gray-900" data-footer-total="1">$' + totalSum.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-right font-semibold text-gray-900" data-footer-total="1">' + formatMoneyNoDecimals(totalSum) + '</td>';
|
||||
html += '<td class="px-4 py-3"></td>';
|
||||
html += '<td class="px-4 py-3"></td>';
|
||||
html += '<td class="px-4 py-3"></td>';
|
||||
html += '</tr>';
|
||||
@@ -994,6 +1013,105 @@ document.addEventListener('keydown', function(e) {
|
||||
}
|
||||
});
|
||||
|
||||
function onConfigDragStart(event) {
|
||||
if (configStatusMode !== 'active' || isReorderingConfigs) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
const row = event.target.closest('tr[data-config-uuid]');
|
||||
if (!row) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
dragConfigUUID = row.dataset.configUuid || '';
|
||||
if (!dragConfigUUID) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
row.classList.add('opacity-50');
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('text/plain', dragConfigUUID);
|
||||
}
|
||||
|
||||
function onConfigDragOver(event) {
|
||||
if (!dragConfigUUID || configStatusMode !== 'active') return;
|
||||
event.preventDefault();
|
||||
const row = event.target.closest('tr[data-config-uuid]');
|
||||
if (!row || row.dataset.configUuid === dragConfigUUID) return;
|
||||
row.classList.add('ring-2', 'ring-blue-300');
|
||||
}
|
||||
|
||||
function onConfigDragLeave(event) {
|
||||
const row = event.target.closest('tr[data-config-uuid]');
|
||||
if (!row) return;
|
||||
row.classList.remove('ring-2', 'ring-blue-300');
|
||||
}
|
||||
|
||||
async function onConfigDrop(event) {
|
||||
if (!dragConfigUUID || configStatusMode !== 'active' || isReorderingConfigs) return;
|
||||
event.preventDefault();
|
||||
|
||||
const targetRow = event.target.closest('tr[data-config-uuid]');
|
||||
if (!targetRow) return;
|
||||
targetRow.classList.remove('ring-2', 'ring-blue-300');
|
||||
|
||||
const targetUUID = targetRow.dataset.configUuid || '';
|
||||
if (!targetUUID || targetUUID === dragConfigUUID) return;
|
||||
|
||||
const previous = allConfigs.slice();
|
||||
const fromIndex = allConfigs.findIndex(c => c.uuid === dragConfigUUID);
|
||||
const toIndex = allConfigs.findIndex(c => c.uuid === targetUUID);
|
||||
if (fromIndex < 0 || toIndex < 0) return;
|
||||
|
||||
const [moved] = allConfigs.splice(fromIndex, 1);
|
||||
allConfigs.splice(toIndex, 0, moved);
|
||||
renderConfigs(allConfigs);
|
||||
await saveConfigReorder(previous);
|
||||
}
|
||||
|
||||
function onConfigDragEnd() {
|
||||
document.querySelectorAll('tr[data-config-uuid]').forEach(row => {
|
||||
row.classList.remove('ring-2', 'ring-blue-300', 'opacity-50');
|
||||
});
|
||||
dragConfigUUID = '';
|
||||
}
|
||||
|
||||
async function saveConfigReorder(previousConfigs) {
|
||||
if (isReorderingConfigs) return;
|
||||
isReorderingConfigs = true;
|
||||
const orderedUUIDs = allConfigs.map(c => c.uuid);
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/projects/' + projectUUID + '/configs/reorder', {
|
||||
method: 'PATCH',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ordered_uuids: orderedUUIDs}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Не удалось сохранить порядок');
|
||||
}
|
||||
const data = await resp.json();
|
||||
allConfigs = data.configurations || allConfigs;
|
||||
renderConfigs(allConfigs);
|
||||
if (typeof showToast === 'function') {
|
||||
showToast('Порядок сохранён', 'success');
|
||||
}
|
||||
} catch (e) {
|
||||
allConfigs = previousConfigs.slice();
|
||||
renderConfigs(allConfigs);
|
||||
if (typeof showToast === 'function') {
|
||||
showToast(e.message || 'Не удалось сохранить порядок', 'error');
|
||||
} else {
|
||||
alert(e.message || 'Не удалось сохранить порядок');
|
||||
}
|
||||
} finally {
|
||||
isReorderingConfigs = false;
|
||||
dragConfigUUID = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function updateConfigServerCount(input) {
|
||||
const uuid = input.dataset.uuid;
|
||||
const prevValue = parseInt(input.dataset.prev) || 1;
|
||||
@@ -1018,7 +1136,7 @@ async function updateConfigServerCount(input) {
|
||||
// Update row total price
|
||||
const totalCell = document.querySelector('[data-total-uuid="' + uuid + '"]');
|
||||
if (totalCell && updated.total_price != null) {
|
||||
totalCell.textContent = '$' + updated.total_price.toLocaleString('en-US', {minimumFractionDigits: 2});
|
||||
totalCell.textContent = formatMoneyNoDecimals(updated.total_price);
|
||||
}
|
||||
// Update the config in allConfigs and recalculate footer total
|
||||
for (let i = 0; i < allConfigs.length; i++) {
|
||||
@@ -1040,7 +1158,7 @@ function updateFooterTotal() {
|
||||
allConfigs.forEach(c => { totalSum += (c.total_price || 0); });
|
||||
const footer = document.querySelector('tfoot td[data-footer-total]');
|
||||
if (footer) {
|
||||
footer.textContent = '$' + totalSum.toLocaleString('en-US', {minimumFractionDigits: 2});
|
||||
footer.textContent = formatMoneyNoDecimals(totalSum);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user