v3: загрузка файлов (скрепка)

This commit is contained in:
Ahtanova.D 2026-08-21 10:53:29 +00:00
parent fb0ccc1573
commit 9b31f446bd
2 changed files with 201 additions and 8 deletions

View File

@ -24,6 +24,7 @@
.btn-primary:hover { background: #0056b3; } .btn-primary:hover { background: #0056b3; }
.btn-success { background: #34C759; color: white; } .btn-success { background: #34C759; color: white; }
.btn-danger { background: #FF3B30; color: white; padding: 8px 16px; font-size: 13px; } .btn-danger { background: #FF3B30; color: white; padding: 8px 16px; font-size: 13px; }
.btn-secondary { background: #8e8e93; color: white; }
table { width: 100%; border-collapse: collapse; margin-top: 16px; font-size: 14px; } table { width: 100%; border-collapse: collapse; margin-top: 16px; font-size: 14px; }
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #e5e5ea; } th, td { padding: 12px; text-align: left; border-bottom: 1px solid #e5e5ea; }
th { background: #f5f5f7; font-weight: 600; font-size: 13px; color: #6e6e73; text-transform: uppercase; } th { background: #f5f5f7; font-weight: 600; font-size: 13px; color: #6e6e73; text-transform: uppercase; }
@ -32,7 +33,6 @@
.checkbox-group input[type="checkbox"] { width: 20px; height: 20px; margin-top: 2px; } .checkbox-group input[type="checkbox"] { width: 20px; height: 20px; margin-top: 2px; }
.checkbox-group label { font-size: 14px; line-height: 1.4; cursor: pointer; } .checkbox-group label { font-size: 14px; line-height: 1.4; cursor: pointer; }
.price-highlight { background: #d4edda; font-weight: 600; } .price-highlight { background: #d4edda; font-weight: 600; }
.price-low { background: #fff3cd; }
.empty { text-align: center; padding: 40px; color: #86868b; } .empty { text-align: center; padding: 40px; color: #86868b; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 16px; margin-bottom: 24px; } .stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 16px; margin-bottom: 24px; }
.stat { background: white; padding: 20px; border-radius: 12px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,0.08); } .stat { background: white; padding: 20px; border-radius: 12px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
@ -41,8 +41,17 @@
.conclusion-box { background: #f0f8ff; border: 2px solid #007AFF; border-radius: 12px; padding: 20px; margin-top: 20px; } .conclusion-box { background: #f0f8ff; border: 2px solid #007AFF; border-radius: 12px; padding: 20px; margin-top: 20px; }
.signature-block { margin-top: 40px; display: grid; grid-template-columns: 1fr 1fr; gap: 40px; } .signature-block { margin-top: 40px; display: grid; grid-template-columns: 1fr 1fr; gap: 40px; }
.signature-line { border-top: 1px solid #1d1d1f; padding-top: 8px; margin-top: 40px; } .signature-line { border-top: 1px solid #1d1d1f; padding-top: 8px; margin-top: 40px; }
.file-list { margin-top: 16px; }
.file-item { display: flex; align-items: center; justify-content: space-between; padding: 12px; background: #f9f9fb; border-radius: 8px; margin-bottom: 8px; }
.file-item a { color: #007AFF; text-decoration: none; font-weight: 500; }
.file-item a:hover { text-decoration: underline; }
.file-meta { font-size: 12px; color: #86868b; margin-left: 12px; }
.upload-area { border: 2px dashed #d2d2d7; border-radius: 12px; padding: 24px; text-align: center; background: #fafafa; cursor: pointer; transition: all 0.2s; }
.upload-area:hover { border-color: #007AFF; background: #f0f8ff; }
.upload-area.dragover { border-color: #34C759; background: #e8f5e9; }
.upload-icon { font-size: 48px; margin-bottom: 12px; }
@media print { @media print {
.btn, .no-print { display: none !important; } .btn, .no-print, .upload-area { display: none !important; }
.card { box-shadow: none; border: 1px solid #ddd; } .card { box-shadow: none; border: 1px solid #ddd; }
} }
</style> </style>
@ -56,7 +65,7 @@
<div class="container"> <div class="container">
<div class="no-print" style="text-align: right; margin-bottom: 20px;"> <div class="no-print" style="text-align: right; margin-bottom: 20px;">
<button class="btn btn-primary" onclick="window.print()">🖨 Печать / PDF</button> <button class="btn btn-primary" onclick="window.print()">🖨 Печать / PDF</button>
<button class="btn btn-success" onclick="exportToExcel()">📥 Выгрузить в Excel</button> <button class="btn btn-success" onclick="exportToExcel()">📤 Выгрузить в Excel</button>
</div> </div>
<!-- Раздел 1: Входные данные --> <!-- Раздел 1: Входные данные -->
@ -81,6 +90,18 @@
<label>Техническая спецификация</label> <label>Техническая спецификация</label>
<textarea name="techSpec" placeholder="описание товара/работы/услуги"></textarea> <textarea name="techSpec" placeholder="описание товара/работы/услуги"></textarea>
</div> </div>
<div class="form-group">
<label>📎 Прикрепить файлы (ТЗ, спецификации, письма)</label>
<div class="upload-area" id="uploadArea1" onclick="document.getElementById('fileInput1').click()">
<div class="upload-icon">📎</div>
<div>Нажмите для загрузки или перетащите файлы сюда</div>
<div style="font-size: 12px; color: #86868b; margin-top: 8px;">DOCX, XLSX, PDF до 10 МБ</div>
</div>
<input type="file" id="fileInput1" multiple accept=".doc,.docx,.xls,.xlsx,.pdf" style="display: none;" onchange="handleFiles(this.files, 'Входные данные')">
</div>
<div class="file-list" id="filesSection1"></div>
<button type="submit" class="btn btn-primary">Сохранить</button> <button type="submit" class="btn btn-primary">Сохранить</button>
</form> </form>
<div id="inputDataDisplay" style="margin-top: 20px; padding: 16px; background: #f9f9fb; border-radius: 10px; display: none;"></div> <div id="inputDataDisplay" style="margin-top: 20px; padding: 16px; background: #f9f9fb; border-radius: 10px; display: none;"></div>
@ -117,6 +138,17 @@
<label>Контактные данные (веб-сайт, телефон, адрес, email)</label> <label>Контактные данные (веб-сайт, телефон, адрес, email)</label>
<input type="text" name="contacts" placeholder="+7 XXX XXX-XX-XX, example.kz, info@example.kz"> <input type="text" name="contacts" placeholder="+7 XXX XXX-XX-XX, example.kz, info@example.kz">
</div> </div>
<div class="form-group">
<label>📎 Прикрепить файлы (КП, уставные документы)</label>
<div class="upload-area" onclick="document.getElementById('fileInput2').click()">
<div class="upload-icon">📎</div>
<div>Нажмите для загрузки файлов</div>
</div>
<input type="file" id="fileInput2" multiple accept=".doc,.docx,.xls,.xlsx,.pdf" style="display: none;" onchange="handleFiles(this.files, 'Поставщики')">
</div>
<div class="file-list" id="filesSection2"></div>
<button type="submit" class="btn btn-success">Добавить поставщика</button> <button type="submit" class="btn btn-success">Добавить поставщика</button>
</form> </form>
@ -151,6 +183,17 @@
<input type="date" name="date"> <input type="date" name="date">
</div> </div>
</div> </div>
<div class="form-group">
<label>📎 Прикрепить файлы (КП, письма)</label>
<div class="upload-area" onclick="document.getElementById('fileInput3').click()">
<div class="upload-icon">📎</div>
<div>Нажмите для загрузки файлов</div>
</div>
<input type="file" id="fileInput3" multiple accept=".doc,.docx,.xls,.xlsx,.pdf" style="display: none;" onchange="handleFiles(this.files, 'Предложения')">
</div>
<div class="file-list" id="filesSection3"></div>
<button type="submit" class="btn btn-success">Добавить предложение</button> <button type="submit" class="btn btn-success">Добавить предложение</button>
</form> </form>
@ -168,15 +211,15 @@
<h2 class="section-title"><span class="section-num">4</span>Заключение по проверке (ст. 31 Порядка закупок)</h2> <h2 class="section-title"><span class="section-num">4</span>Заключение по проверке (ст. 31 Порядка закупок)</h2>
<div class="checkbox-group"> <div class="checkbox-group">
<input type="checkbox" id="check1"> <input type="checkbox" id="check1">
<label for="check1">Потенциальный поставщик или его субподрядчик (соисполнитель) либо участник консорциума НЕ состоит в реестре недобросовестных участников государственных закупок и (или) в реестре недобросовестных участников закупок, и (или) в перечне ненадежных потенциальных поставщиков (поставщиков) Фонда, и (или) в списке должников, в отношении которых вступило в законную силу решение суда о признании их банкротами</label> <label for="check1">Потенциальный поставщик или его субподрядчик НЕ состоит в реестре недобросовестных участников</label>
</div> </div>
<div class="checkbox-group"> <div class="checkbox-group">
<input type="checkbox" id="check2"> <input type="checkbox" id="check2">
<label for="check2">Потенциальный поставщик и (или) привлекаемый им субподрядчик (соисполнитель), и (или) их руководитель, и (или) учредители (акционеры) НЕ включены в перечень организаций и лиц, связанных с финансированием терроризма и экстремизма</label> <label for="check2">НЕ включены в перечень организаций, связанных с финансированием терроризма</label>
</div> </div>
<div class="checkbox-group"> <div class="checkbox-group">
<input type="checkbox" id="check3"> <input type="checkbox" id="check3">
<label for="check3">Потенциальный поставщик НЕ является юридическим лицом, местом регистрации которого является государство или территория, включенные в перечень государств с льготным налогообложением</label> <label for="check3">НЕ является юридическим лицом из государства с льготным налогообложением</label>
</div> </div>
<button class="btn btn-primary no-print" onclick="saveChecks()">Сохранить проверку</button> <button class="btn btn-primary no-print" onclick="saveChecks()">Сохранить проверку</button>
</div> </div>
@ -243,10 +286,16 @@
<p style="margin-top: 16px;">«___» __________ 2026 г.</p> <p style="margin-top: 16px;">«___» __________ 2026 г.</p>
</div> </div>
</div> </div>
<!-- Все файлы -->
<div class="card">
<h2 class="section-title">📎 Все прикреплённые файлы</h2>
<div class="file-list" id="allFilesList"></div>
</div>
</div> </div>
<script> <script>
let data = { inputData: null, suppliers: [], offers: [], checks: { c1: false, c2: false, c3: false } }; let data = { inputData: null, suppliers: [], offers: [], checks: { c1: false, c2: false, c3: false }, files: [] };
async function loadData() { async function loadData() {
const r = await fetch('/api/data'); const r = await fetch('/api/data');
@ -259,6 +308,7 @@
document.getElementById('check1').checked = data.checks.c1; document.getElementById('check1').checked = data.checks.c1;
document.getElementById('check2').checked = data.checks.c2; document.getElementById('check2').checked = data.checks.c2;
document.getElementById('check3').checked = data.checks.c3; document.getElementById('check3').checked = data.checks.c3;
renderFiles();
generateConclusion(); generateConclusion();
} }
@ -323,6 +373,54 @@
generateConclusion(min, avg); generateConclusion(min, avg);
} }
function renderFiles() {
const allFiles = document.getElementById('allFilesList');
const section1 = document.getElementById('filesSection1');
const section2 = document.getElementById('filesSection2');
const section3 = document.getElementById('filesSection3');
const inputFiles = data.files.filter(f => f.section === 'Входные данные');
const supplierFiles = data.files.filter(f => f.section === 'Поставщики');
const offerFiles = data.files.filter(f => f.section === 'Предложения');
function renderFileList(files, container) {
if (files.length === 0) { container.innerHTML = ''; return; }
container.innerHTML = files.map(f =>
'<div class="file-item">' +
'<a href="' + f.path + '" download>📄 ' + f.name + '</a>' +
'<span class="file-meta">' + new Date(f.date).toLocaleDateString() + '</span>' +
'<button class="btn btn-danger no-print" onclick="deleteFile(\'' + f.path.split('/').pop() + '\')">✕</button>' +
'</div>'
).join('');
}
renderFileList(inputFiles, section1);
renderFileList(supplierFiles, section2);
renderFileList(offerFiles, section3);
renderFileList(data.files, allFiles);
}
async function handleFiles(files, section) {
if (!files.length) return;
const formData = new FormData();
for (const f of files) {
formData.append('file', f);
}
formData.append('section', section);
const r = await fetch('/api/upload', { method: 'POST', body: formData });
const result = await r.json();
if (result.ok) {
loadData();
}
}
async function deleteFile(fileName) {
if (!confirm('Удалить файл?')) return;
await fetch('/api/files/' + fileName, { method: 'DELETE' });
loadData();
}
function generateConclusion(min, avg) { function generateConclusion(min, avg) {
const minOffer = data.offers.find(o => Number(o.price) === min); const minOffer = data.offers.find(o => Number(o.price) === min);
const s = minOffer ? data.suppliers.find(x => x.id === minOffer.supplierId) : null; const s = minOffer ? data.suppliers.find(x => x.id === minOffer.supplierId) : null;
@ -447,6 +545,10 @@
rows.push(["5. Анализ", "Средняя цена", avg.toLocaleString() + ' тенге']); rows.push(["5. Анализ", "Средняя цена", avg.toLocaleString() + ' тенге']);
rows.push(["5. Анализ", "Количество предложений", data.offers.length]); rows.push(["5. Анализ", "Количество предложений", data.offers.length]);
data.files.forEach(f => {
rows.push(["Файлы", f.name, f.section]);
});
const csv = '\uFEFF' + [head.join(';'), ...rows.map(r => r.map(v => '"' + String(v).replace(/"/g, '""') + '"').join(';'))].join('\n'); const csv = '\uFEFF' + [head.join(';'), ...rows.map(r => r.map(v => '"' + String(v).replace(/"/g, '""') + '"').join(';'))].join('\n');
const a = document.createElement('a'); const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' })); a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }));
@ -454,6 +556,27 @@
a.click(); a.click();
} }
const uploadAreas = document.querySelectorAll('.upload-area');
uploadAreas.forEach(area => {
area.addEventListener('dragover', (e) => {
e.preventDefault();
area.classList.add('dragover');
});
area.addEventListener('dragleave', () => {
area.classList.remove('dragover');
});
area.addEventListener('drop', (e) => {
e.preventDefault();
area.classList.remove('dragover');
const input = area.parentElement.querySelector('input[type="file"]');
if (input) {
input.files = e.dataTransfer.files;
const event = new Event('change');
input.dispatchEvent(event);
}
});
});
loadData(); loadData();
</script> </script>
</body> </body>

View File

@ -1,12 +1,16 @@
const http = require("http"); const http = require("http");
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const formidable = require("formidable");
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
const DATA = path.join(__dirname, "data.json"); const DATA = path.join(__dirname, "data.json");
const UPLOADS = path.join(__dirname, "uploads");
if (!fs.existsSync(UPLOADS)) fs.mkdirSync(UPLOADS);
function readData() { function readData() {
try { return JSON.parse(fs.readFileSync(DATA, "utf8")); } catch (_) { return { inputData: null, suppliers: [], offers: [], checks: { c1: false, c2: false, c3: false } }; } try { return JSON.parse(fs.readFileSync(DATA, "utf8")); } catch (_) { return { inputData: null, suppliers: [], offers: [], checks: { c1: false, c2: false, c3: false }, files: [] }; }
} }
function writeData(data) { function writeData(data) {
fs.writeFileSync(DATA, JSON.stringify(data, null, 2)); fs.writeFileSync(DATA, JSON.stringify(data, null, 2));
@ -25,6 +29,72 @@ http.createServer(async (req, res) => {
return res.end(JSON.stringify(readData())); return res.end(JSON.stringify(readData()));
} }
if (req.method === "GET" && req.url === "/api/files") {
const data = readData();
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify(data.files || []));
}
if (req.method === "POST" && req.url === "/api/upload") {
const form = formidable({ uploadDir: UPLOADS, keepExtensions: true });
form.parse(req, async (err, fields, files) => {
if (err) {
res.writeHead(500, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ error: err.message }));
}
const data = readData();
const uploaded = [];
if (files.file && Array.isArray(files.file)) {
for (const f of files.file) {
const fileName = Date.now() + "_" + path.basename(f.filepath);
const newPath = path.join(UPLOADS, fileName);
fs.renameSync(f.filepath, newPath);
const fileRec = {
id: Date.now() + Math.random(),
name: f.originalFilename || fileName,
path: "/api/files/" + fileName,
section: fields.section ? fields.section[0] : "other",
size: f.size,
date: new Date().toISOString()
};
data.files.push(fileRec);
uploaded.push(fileRec);
}
}
writeData(data);
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ ok: true, files: uploaded }));
});
return;
}
if (req.method === "GET" && req.url.startsWith("/api/files/")) {
const fileName = req.url.split("/")[3];
const filePath = path.join(UPLOADS, fileName);
if (fs.existsSync(filePath)) {
const ext = path.extname(fileName).toLowerCase();
const mime = ext === ".pdf" ? "application/pdf" : ext === ".docx" ? "application/vnd.openxmlformats-officedocument.wordprocessingml.document" : ext === ".xlsx" ? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" : "application/octet-stream";
res.writeHead(200, { "Content-Type": mime, "Content-Disposition": "attachment; filename=\"" + fileName + "\"" });
return res.end(fs.readFileSync(filePath));
}
res.writeHead(404); res.end("File not found");
return;
}
if (req.method === "DELETE" && req.url.startsWith("/api/files/")) {
const fileName = req.url.split("/")[3];
const data = readData();
const fileRec = data.files.find(f => f.path.endsWith(fileName));
if (fileRec) {
const filePath = path.join(UPLOADS, fileName);
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
data.files = data.files.filter(f => f.path !== fileRec.path);
writeData(data);
}
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ ok: true }));
}
if (req.method === "POST" && req.url === "/api/inputData") { if (req.method === "POST" && req.url === "/api/inputData") {
const d = await body(req); const d = await body(req);
const data = readData(); const data = readData();