| | | 1 | | @model string |
| | | 2 | | @{ |
| | 0 | 3 | | var article = Model; |
| | 0 | 4 | | ViewData["Title"] = string.IsNullOrEmpty(article) ? "Карта движений" : $"Карта движений: {article}"; |
| | | 5 | | } |
| | | 6 | | |
| | | 7 | | <style> |
| | | 8 | | .mm-skeleton { background: linear-gradient(90deg, #e9ecef 25%, #dee2e6 50%, #e9ecef 75%); background-size: 200% 100% |
| | | 9 | | @@keyframes mm-shimmer { 0%{background-position:200% 0} 100%{background-position:-200% 0} } |
| | | 10 | | .mm-badge-receipt { background: #198754; color: #fff; } |
| | | 11 | | .mm-badge-expense { background: #dc3545; color: #fff; } |
| | | 12 | | .mm-badge-transfer { background: #0d6efd; color: #fff; } |
| | | 13 | | .mm-row-deleted td { opacity: .45; text-decoration: line-through; } |
| | | 14 | | .mm-row-unposted td { opacity: .7; font-style: italic; } |
| | | 15 | | .mm-arrow { color: #adb5bd; } |
| | | 16 | | .mm-wh-pill { font-size: .85rem; } |
| | | 17 | | #mm-table tbody tr:hover { background: #f8f9fa; } |
| | | 18 | | .mm-balance-chip { display: inline-block; background: #198754; color: #fff; border-radius: 6px; padding: 1px 8px; fo |
| | | 19 | | .mm-filter-btn.active { font-weight: 600; } |
| | | 20 | | </style> |
| | | 21 | | |
| | | 22 | | <div class="container-fluid mt-3"> |
| | | 23 | | |
| | | 24 | | <div class="card mb-3"> |
| | | 25 | | <div class="card-header d-flex align-items-center gap-2"> |
| | | 26 | | <a href="javascript:history.back()" class="btn btn-sm btn-outline-secondary me-1"><i class="fas fa-arrow-lef |
| | | 27 | | <i class="fas fa-chart-line me-1"></i> |
| | | 28 | | <span>Карта движений по 1С</span> |
| | 0 | 29 | | @if (!string.IsNullOrEmpty(article)) |
| | | 30 | | { |
| | | 31 | | <span class="text-muted">·</span> |
| | 0 | 32 | | <strong>@article</strong> |
| | | 33 | | <span class="text-muted fw-normal small" id="mm-nom-name"></span> |
| | | 34 | | } |
| | | 35 | | </div> |
| | | 36 | | <div class="card-body pb-2"> |
| | | 37 | | |
| | 0 | 38 | | @if (string.IsNullOrEmpty(article)) |
| | | 39 | | { |
| | | 40 | | <form method="get" asp-area="Studio2" asp-controller="WarehouseStock" asp-action="MovementMap" class="ro |
| | | 41 | | <div class="col-auto"> |
| | | 42 | | <input type="text" name="article" class="form-control" placeholder="Артикул..." autofocus style= |
| | | 43 | | </div> |
| | | 44 | | <div class="col-auto"> |
| | | 45 | | <button class="btn btn-primary"><i class="fas fa-search me-1"></i>Найти</button> |
| | | 46 | | </div> |
| | | 47 | | </form> |
| | | 48 | | } |
| | | 49 | | else |
| | | 50 | | { |
| | | 51 | | <div class="mb-2"> |
| | | 52 | | <span class="text-muted small me-2">Текущий остаток:</span> |
| | | 53 | | <span id="mm-balance-line"><span class="mm-skeleton d-inline-block" style="height:22px;width:100px;v |
| | | 54 | | </div> |
| | | 55 | | |
| | | 56 | | <!-- Сводка --> |
| | | 57 | | <div class="row g-2 mb-3" id="mm-stats-row"> |
| | 0 | 58 | | @for (int i = 0; i < 4; i++) { |
| | | 59 | | <div class="col-6 col-md-3"><div class="mm-skeleton" style="height:66px;"></div></div> |
| | | 60 | | } |
| | | 61 | | </div> |
| | | 62 | | |
| | | 63 | | <!-- Фильтры --> |
| | | 64 | | <div class="d-flex align-items-center gap-2 mb-2 flex-wrap"> |
| | | 65 | | <div class="btn-group btn-group-sm"> |
| | | 66 | | <button class="btn btn-outline-secondary mm-filter-btn active" data-kind="all">Все</button> |
| | | 67 | | <button class="btn btn-outline-success mm-filter-btn" data-kind="receipt">Приход</button> |
| | | 68 | | <button class="btn btn-outline-danger mm-filter-btn" data-kind="expense">Расход</button> |
| | | 69 | | <button class="btn btn-outline-primary mm-filter-btn" data-kind="transfer">Перемещения</button> |
| | | 70 | | </div> |
| | | 71 | | <div class="form-check form-switch mb-0 ms-2"> |
| | | 72 | | <input class="form-check-input" type="checkbox" id="mm-hide-deleted"> |
| | | 73 | | <label class="form-check-label small" for="mm-hide-deleted">Скрыть удалённые</label> |
| | | 74 | | </div> |
| | | 75 | | <span class="ms-auto small text-muted" id="mm-count-label"></span> |
| | | 76 | | </div> |
| | | 77 | | } |
| | | 78 | | </div> |
| | | 79 | | </div> |
| | | 80 | | |
| | 0 | 81 | | @if (!string.IsNullOrEmpty(article)) |
| | | 82 | | { |
| | | 83 | | <div class="card"> |
| | | 84 | | <div class="table-responsive"> |
| | | 85 | | <table class="table table-sm table-hover mb-0" id="mm-table"> |
| | | 86 | | <thead class="table-light"> |
| | | 87 | | <tr> |
| | | 88 | | <th style="width:95px">Дата</th> |
| | | 89 | | <th style="width:115px">Тип</th> |
| | | 90 | | <th>Документ</th> |
| | | 91 | | <th style="width:75px" class="text-end">Кол-во</th> |
| | | 92 | | <th style="width:85px" class="text-end">Цена</th> |
| | | 93 | | <th>Склад</th> |
| | | 94 | | <th style="width:80px" class="text-end">Остаток</th> |
| | | 95 | | </tr> |
| | | 96 | | </thead> |
| | | 97 | | <tbody id="mm-tbody"> |
| | | 98 | | <tr><td colspan="7" class="p-3"> |
| | | 99 | | <div class="d-flex flex-column gap-2"> |
| | 0 | 100 | | @for (int i = 0; i < 6; i++) { <div class="mm-skeleton" style="height:30px;"></div> } |
| | | 101 | | </div> |
| | | 102 | | </td></tr> |
| | | 103 | | </tbody> |
| | | 104 | | </table> |
| | | 105 | | </div> |
| | | 106 | | </div> |
| | | 107 | | |
| | | 108 | | <div id="mm-error" class="alert alert-danger mt-3 d-none"></div> |
| | | 109 | | } |
| | | 110 | | </div> |
| | | 111 | | |
| | | 112 | | @section Scripts { |
| | | 113 | | <script> |
| | | 114 | | (function () { |
| | 0 | 115 | | const article = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(article)); |
| | 0 | 116 | | const accessKey = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(ViewBag.AccessKey as string ?? "")); |
| | | 117 | | if (!article) return; |
| | | 118 | | |
| | | 119 | | function accessKeyQs() { |
| | | 120 | | return accessKey ? '&accessKey=' + encodeURIComponent(accessKey) : ''; |
| | | 121 | | } |
| | | 122 | | |
| | | 123 | | let allMovements = []; |
| | | 124 | | let activeKind = 'all'; |
| | | 125 | | let hideDeleted = false; |
| | | 126 | | |
| | | 127 | | function fmtDate(s) { const [y,m,d]=s.split('-'); return `${d}.${m}.${y}`; } |
| | | 128 | | function fmtQty(n) { return (+n).toLocaleString('ru-RU',{maximumFractionDigits:3}); } |
| | | 129 | | function fmtPrice(n) { return n>0 ? (+n).toLocaleString('ru-RU',{minimumFractionDigits:2,maximumFractionDigits:2}) : |
| | | 130 | | |
| | | 131 | | function kindBadge(kind) { |
| | | 132 | | const m = { receipt:['mm-badge-receipt','<i class="fas fa-arrow-down me-1"></i>Приход'], expense:['mm-badge-expe |
| | | 133 | | const [cls,html] = m[kind]||['bg-secondary text-white',kind]; |
| | | 134 | | return `<span class="badge ${cls}">${html}</span>`; |
| | | 135 | | } |
| | | 136 | | |
| | | 137 | | function warehouseCell(from, to, kind) { |
| | | 138 | | if (kind==='receipt') return `<span class="mm-wh-pill">${to||'—'}</span>`; |
| | | 139 | | if (kind==='expense') return `<span class="mm-wh-pill">${from||'—'}</span>`; |
| | | 140 | | return `<span class="mm-wh-pill">${from||'?'}</span><span class="mm-arrow mx-1">→</span><span class="mm-wh-pill" |
| | | 141 | | } |
| | | 142 | | |
| | | 143 | | function renderStats(s, balance) { |
| | | 144 | | const chips = balance.map(b=>`<span class="mm-balance-chip">${b.warehouse}: ${fmtQty(b.quantity)}</span>`).join( |
| | | 145 | | document.getElementById('mm-balance-line').innerHTML = chips || '<span class="text-muted">Нет остатков</span>'; |
| | | 146 | | |
| | | 147 | | document.getElementById('mm-stats-row').innerHTML = ` |
| | | 148 | | <div class="col-6 col-md-3"> |
| | | 149 | | <div class="card border-success h-100"> |
| | | 150 | | <div class="card-body py-2 px-3"> |
| | | 151 | | <div class="text-success fw-bold fs-5">+${fmtQty(s.receiptQty)}</div> |
| | | 152 | | <div class="text-muted small">${s.receiptDocs} приход. накл.</div> |
| | | 153 | | </div> |
| | | 154 | | </div> |
| | | 155 | | </div> |
| | | 156 | | <div class="col-6 col-md-3"> |
| | | 157 | | <div class="card border-danger h-100"> |
| | | 158 | | <div class="card-body py-2 px-3"> |
| | | 159 | | <div class="text-danger fw-bold fs-5">−${fmtQty(s.expenseQty)}</div> |
| | | 160 | | <div class="text-muted small">${s.expenseDocs} расход. накл.</div> |
| | | 161 | | </div> |
| | | 162 | | </div> |
| | | 163 | | </div> |
| | | 164 | | <div class="col-6 col-md-3"> |
| | | 165 | | <div class="card border-primary h-100"> |
| | | 166 | | <div class="card-body py-2 px-3"> |
| | | 167 | | <div class="text-primary fw-bold fs-5">↔${fmtQty(s.transferQty)}</div> |
| | | 168 | | <div class="text-muted small">${s.transferDocs} перемещ.</div> |
| | | 169 | | </div> |
| | | 170 | | </div> |
| | | 171 | | </div> |
| | | 172 | | <div class="col-6 col-md-3"> |
| | | 173 | | <div class="card h-100"> |
| | | 174 | | <div class="card-body py-2 px-3"> |
| | | 175 | | <div class="fw-bold fs-5">${fmtQty(s.calculatedBalance)}</div> |
| | | 176 | | <div class="text-muted small">расчётный остаток</div> |
| | | 177 | | </div> |
| | | 178 | | </div> |
| | | 179 | | </div>`; |
| | | 180 | | } |
| | | 181 | | |
| | | 182 | | function renderTable() { |
| | | 183 | | const filtered = allMovements.filter(m => { |
| | | 184 | | if (activeKind !== 'all' && m.kind !== activeKind) return false; |
| | | 185 | | if (hideDeleted && m.isDeleted) return false; |
| | | 186 | | return true; |
| | | 187 | | }); |
| | | 188 | | |
| | | 189 | | document.getElementById('mm-count-label').textContent = `${filtered.length} записей`; |
| | | 190 | | |
| | | 191 | | let bal = 0; |
| | | 192 | | const balMap = new Map(); |
| | | 193 | | for (const m of allMovements) { |
| | | 194 | | if (!m.isDeleted) { |
| | | 195 | | if (m.kind==='receipt') bal += m.quantity; |
| | | 196 | | else if (m.kind==='expense') bal -= m.quantity; |
| | | 197 | | } |
| | | 198 | | balMap.set(m, bal); |
| | | 199 | | } |
| | | 200 | | |
| | | 201 | | const rows = filtered.map(m => { |
| | | 202 | | const cls = m.isDeleted ? 'mm-row-deleted' : (!m.isPosted ? 'mm-row-unposted' : ''); |
| | | 203 | | const b = balMap.get(m); |
| | | 204 | | const balCell = (m.kind!=='transfer' && !m.isDeleted) |
| | | 205 | | ? `<span class="badge ${b>=0?'bg-success':'bg-danger'} bg-opacity-75">${fmtQty(b)}</span>` |
| | | 206 | | : '<span class="text-muted">—</span>'; |
| | | 207 | | const marks = (m.isDeleted ? '<span class="badge bg-secondary ms-1 opacity-50" style="font-size:.7em">удалён |
| | | 208 | | + (!m.isPosted&&!m.isDeleted ? '<span class="badge bg-warning text-dark ms-1" style="font-size:. |
| | | 209 | | const docLink = m.refKey |
| | | 210 | | ? `<a href="/Studio2/OneCDocument/Details?refKey=${m.refKey}${accessKeyQs()}" target="_blank" class="fw- |
| | | 211 | | : `<span class="fw-medium">#${m.docNumber}</span>`; |
| | | 212 | | return `<tr class="${cls}"> |
| | | 213 | | <td class="text-muted small text-nowrap">${fmtDate(m.date)}</td> |
| | | 214 | | <td>${kindBadge(m.kind)}</td> |
| | | 215 | | <td>${docLink}${marks}</td> |
| | | 216 | | <td class="text-end fw-semibold">${m.kind==='receipt'?'+':m.kind==='expense'?'−':'↔'}${fmtQty(m.quantity |
| | | 217 | | <td class="text-end text-muted small">${fmtPrice(m.price)}</td> |
| | | 218 | | <td>${warehouseCell(m.warehouseFrom,m.warehouseTo,m.kind)}</td> |
| | | 219 | | <td class="text-end">${balCell}</td> |
| | | 220 | | </tr>`; |
| | | 221 | | }).join(''); |
| | | 222 | | |
| | | 223 | | document.getElementById('mm-tbody').innerHTML = rows || St2EmptyState.tableRow(7, { title: 'Движений пока нет', |
| | | 224 | | } |
| | | 225 | | |
| | | 226 | | async function load() { |
| | | 227 | | const feedback = OperationFeedback.begin({ |
| | | 228 | | title: 'Загружаем движения склада', |
| | | 229 | | steps: ['Подготавливаем запрос', 'Получаем данные из 1С', 'Строим карту движений'] |
| | | 230 | | }); |
| | | 231 | | try { |
| | | 232 | | feedback.step(1); |
| | | 233 | | const resp = await PageLoadingBar.run(() => fetch(`/Studio2/WarehouseStock/movement-api?article=${encodeURIC |
| | | 234 | | const data = await resp.json(); |
| | | 235 | | if (!data.found) { |
| | | 236 | | document.getElementById('mm-error').classList.remove('d-none'); |
| | | 237 | | document.getElementById('mm-error').textContent = data.error || 'Артикул не найден в 1С'; |
| | | 238 | | document.getElementById('mm-tbody').innerHTML = ''; |
| | | 239 | | document.getElementById('mm-stats-row').innerHTML = '<div class="col-12 text-muted small">—</div>'; |
| | | 240 | | document.getElementById('mm-balance-line').innerHTML = '<span class="text-muted">—</span>'; |
| | | 241 | | return; |
| | | 242 | | } |
| | | 243 | | document.getElementById('mm-nom-name').textContent = data.name !== article ? `· ${data.name}` : ''; |
| | | 244 | | feedback.step(2); |
| | | 245 | | renderStats(data.summary, data.currentBalance); |
| | | 246 | | allMovements = data.movements; |
| | | 247 | | renderTable(); |
| | | 248 | | } catch (e) { |
| | | 249 | | document.getElementById('mm-error').classList.remove('d-none'); |
| | | 250 | | document.getElementById('mm-error').textContent = 'Ошибка загрузки: ' + e.message; |
| | | 251 | | } finally { |
| | | 252 | | feedback.finish(); |
| | | 253 | | } |
| | | 254 | | } |
| | | 255 | | |
| | | 256 | | document.querySelectorAll('.mm-filter-btn').forEach(btn => { |
| | | 257 | | btn.addEventListener('click', () => { |
| | | 258 | | document.querySelectorAll('.mm-filter-btn').forEach(b => b.classList.remove('active')); |
| | | 259 | | btn.classList.add('active'); |
| | | 260 | | activeKind = btn.dataset.kind; |
| | | 261 | | renderTable(); |
| | | 262 | | }); |
| | | 263 | | }); |
| | | 264 | | document.getElementById('mm-hide-deleted').addEventListener('change', e => { |
| | | 265 | | hideDeleted = e.target.checked; renderTable(); |
| | | 266 | | }); |
| | | 267 | | |
| | | 268 | | load(); |
| | | 269 | | })(); |
| | | 270 | | </script> |
| | | 271 | | } |