Копия admin/sprint-2-links.html от 11.10.2026 без правок, чтобы дальнейшие изменения читались диффом. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1059 lines
115 KiB
HTML
1059 lines
115 KiB
HTML
<meta charset="utf-8">
|
||
<title>Спринт 2 линк</title>
|
||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@500&display=swap">
|
||
<style>
|
||
/* Страница «Спринт 2 линк»: кто кого блокирует в спринте. Карточки — задачи Jira, стрелки — связи, дорожки — эпики.
|
||
Разделы стилей: 1 цвета и темы · 2 основа · 3 закреплённая шапка · 4 кнопки и выпадающие списки · 5 строка управления, легенда,
|
||
предупреждения · 6 доска: дорожки и карточки · 7 панель проблем · 8 экспорт и таблица связей · 9 узкие экраны */
|
||
|
||
/* 1. Цвета и темы: светлая по умолчанию, тёмная по системе или по выбору в меню */
|
||
:root{
|
||
--bg:#f3f4f1; --fg:#1f2321; --muted:#6b7069; --line:#d6d8d0; --card:#ffffff; --panel:#ffffff; --soft:#eaece6;
|
||
--accent:#1f6f8b; --accent-fg:#ffffff; --edge:#9a9f97; --edge-strong:#1f6f8b; --guide:#c2410c;
|
||
--k-decision:#b4542a; --k-analytics:#8a6d1f; --k-dev:#1f6f8b; --k-ui:#5b4b9a; --k-test:#2f7d4f; --k-org:#6b7069;
|
||
--p-crit:#b23a3a; --p-high:#c7772a; --p-mid:#6b7069; --p-low:#9a9f97;
|
||
--font-body:"IBM Plex Sans",system-ui,-apple-system,"Segoe UI",sans-serif;
|
||
--font-mono:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
|
||
}
|
||
@media (prefers-color-scheme: dark){ :root:not([data-theme="light"]){
|
||
--bg:#151817; --fg:#e8e6df; --muted:#9a9f97; --line:#303532; --card:#1f2422; --panel:#1b201e; --soft:#252a28;
|
||
--accent:#6fb3cc; --accent-fg:#101413; --edge:#6c726b; --edge-strong:#6fb3cc; --guide:#f08a5a;
|
||
--k-decision:#e08a5a; --k-analytics:#d4b35a; --k-dev:#6fb3cc; --k-ui:#a493d8; --k-test:#6cc08f; --k-org:#9a9f97;
|
||
--p-crit:#e36b6b; --p-high:#e3a059; --p-mid:#9a9f97; --p-low:#6c726b; color-scheme:dark }}
|
||
:root[data-theme="dark"]{
|
||
--bg:#151817; --fg:#e8e6df; --muted:#9a9f97; --line:#303532; --card:#1f2422; --panel:#1b201e; --soft:#252a28;
|
||
--accent:#6fb3cc; --accent-fg:#101413; --edge:#6c726b; --edge-strong:#6fb3cc; --guide:#f08a5a;
|
||
--k-decision:#e08a5a; --k-analytics:#d4b35a; --k-dev:#6fb3cc; --k-ui:#a493d8; --k-test:#6cc08f; --k-org:#9a9f97;
|
||
--p-crit:#e36b6b; --p-high:#e3a059; --p-mid:#9a9f97; --p-low:#6c726b; color-scheme:dark }
|
||
|
||
/* 2. Основа */
|
||
html,body{min-height:100%}
|
||
body{background:var(--bg);color:var(--fg);font-family:var(--font-body);font-size:15px;line-height:1.45;padding-inline:16px;padding-block:0 32px;margin:0}
|
||
h1{font-size:20px;font-weight:600;margin:0}
|
||
[hidden]{display:none!important}
|
||
.row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||
.spacer{flex:1 1 auto}
|
||
.hint{font-size:13px;color:var(--muted)}
|
||
.warn{color:var(--p-high)}
|
||
|
||
/* 3. Закреплённая шапка: название, файл, статистика, кнопка проблем, меню; под ней строка управления и легенда */
|
||
.sticky{position:sticky;top:env(safe-area-inset-top,0px);z-index:10;background:var(--bg);padding-block:12px 8px;margin:0 0 8px;box-shadow:0 1px 0 var(--line)}
|
||
.top{display:flex;align-items:center;gap:6px 14px;flex-wrap:wrap;margin:0 0 8px;min-height:34px}
|
||
.src{display:inline-flex;align-items:baseline;gap:8px;min-width:0}
|
||
.file-name{font-size:14px;color:var(--muted);max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.link{font:inherit;font-size:14px;color:var(--accent);cursor:pointer;background:none;border:0;padding:0;text-decoration:underline dotted;white-space:nowrap}
|
||
.link:hover,.link:focus-visible{text-decoration-style:solid;outline:none}
|
||
.stats{font-size:14px;color:var(--muted);font-variant-numeric:tabular-nums;white-space:nowrap}
|
||
.stats b{color:var(--fg);font-weight:500}
|
||
|
||
/* 4. Кнопки и выпадающие списки */
|
||
.btn{font:inherit;font-size:14px;line-height:1.2;color:var(--fg);background:var(--card);border:1px solid var(--line);border-radius:6px;padding:0 12px;cursor:pointer;height:34px;box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;white-space:nowrap;gap:5px}
|
||
.btn:hover,.btn:focus-visible{border-color:var(--accent);outline:none}
|
||
.btn[aria-pressed="true"]{background:var(--accent);color:var(--accent-fg);border-color:var(--accent)}
|
||
.btn .n{font-weight:600;color:var(--p-crit)}
|
||
.btn .n.zero{font-weight:500;color:var(--muted)}
|
||
.btn[aria-pressed="true"] .n{color:inherit}
|
||
.btn.sm{height:28px;padding:0 8px;font-size:13px}
|
||
.more-btn{padding:0 10px;font-size:20px;line-height:1;letter-spacing:.05em}
|
||
.dd{position:relative;display:inline-block}
|
||
.dd-btn{max-width:360px;gap:8px;justify-content:space-between}
|
||
.dd-btn .lbl{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.dd-btn .car{font-size:10px;opacity:.7}
|
||
.dd-menu{position:absolute;top:calc(100% + 4px);left:0;z-index:20;min-width:280px;max-width:420px;background:var(--panel);border:1px solid var(--line);border-radius:8px;box-shadow:0 10px 28px rgba(0,0,0,.18);padding:8px;display:flex;flex-direction:column;gap:6px}
|
||
.dd-menu input[type=search]{font:inherit;font-size:14px;color:var(--fg);background:var(--card);border:1px solid var(--line);border-radius:6px;padding:6px 8px;width:100%;box-sizing:border-box}
|
||
.dd-menu input[type=search]:focus-visible{border-color:var(--accent);outline:none}
|
||
.dd-list{max-height:260px;overflow:auto;display:flex;flex-direction:column}
|
||
.dd-item{display:flex;align-items:center;gap:8px;padding:5px 6px;border-radius:5px;cursor:pointer;font-size:14px}
|
||
.dd-item:hover{background:var(--soft)}
|
||
.dd-item input{accent-color:var(--accent);margin:0;width:14px;height:14px;flex:0 0 auto}
|
||
.dd-item .n{margin-left:auto;font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums}
|
||
.dd-item.none{color:var(--muted);font-style:italic}
|
||
.dd-item.zero{opacity:.5}
|
||
.dd-foot{display:flex;gap:6px;justify-content:space-between;border-top:1px solid var(--line);padding-top:6px}
|
||
.dd-foot .btn{height:30px;padding:0 10px;font-size:13.5px}
|
||
.dd-empty{font-size:12.5px;color:var(--muted);padding:6px}
|
||
.dd-sep{border:0;border-top:1px solid var(--line);margin:4px 0}
|
||
/* меню «⋯»: открывается к левому краю, пункты с галочкой — переключатели */
|
||
.dd-menu.menu{right:0;left:auto;min-width:230px;padding:6px}
|
||
.menu button{font:inherit;font-size:14px;text-align:left;background:none;border:0;border-radius:5px;padding:7px 10px;color:var(--fg);cursor:pointer;width:100%;display:flex;justify-content:space-between;align-items:center;gap:10px}
|
||
.menu button:hover,.menu button:focus-visible{background:var(--soft);outline:none}
|
||
.menu button[aria-pressed="true"]::after{content:"✓";color:var(--accent);font-weight:600}
|
||
.menu hr{border:0;border-top:1px solid var(--line);margin:4px 0}
|
||
.menu .danger{color:var(--p-crit)}
|
||
.confirm{display:flex;flex-direction:column;gap:6px;padding:6px 10px;font-size:14px}
|
||
.confirm .row{flex-wrap:nowrap}
|
||
.confirm .btn{height:28px;padding:0 10px;width:auto;flex:1 1 0}
|
||
.confirm .btn.yes{background:var(--p-crit);color:#fff;border-color:var(--p-crit)}
|
||
|
||
/* 5. Строка управления, предупреждения о файле, легенда-фильтр */
|
||
.bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 8px}
|
||
.notice{display:flex;flex-wrap:wrap;gap:4px 16px;font-size:13.5px;color:var(--p-high);margin:0 0 6px}
|
||
.legend{display:flex;flex-wrap:wrap;align-items:center;gap:2px 12px;margin:0;font-size:13px;color:var(--muted)}
|
||
.legend span{display:inline-flex;align-items:center;gap:6px}
|
||
.legend .sep{width:1px;height:14px;background:var(--line)}
|
||
.legend .hint{margin-left:auto}
|
||
.lg{font:inherit;font-size:13px;color:var(--muted);background:none;border:0;padding:2px 5px;border-radius:4px;cursor:pointer;display:inline-flex;align-items:center;gap:6px}
|
||
.lg:hover,.lg:focus-visible{background:var(--soft);color:var(--fg);outline:none}
|
||
.lg.off{opacity:.45;text-decoration:line-through}
|
||
.sw{width:10px;height:10px;border-radius:2px;display:inline-block}
|
||
.ln{width:26px;height:0;border-top:2px solid var(--edge-strong);display:inline-block}
|
||
.ln.dash{border-top-style:dashed;border-top-color:var(--edge)}
|
||
.ln.hand{border-top-color:var(--guide)}
|
||
.ln.crit{border-top-width:4px;border-top-color:var(--guide)}
|
||
.out-sw{width:16px;height:11px;border:1.5px dashed var(--muted);border-radius:3px;display:inline-block}
|
||
|
||
/* 6. Доска: прокрутка только по горизонтали (вертикальная — у страницы), дорожки по эпикам, карточки абсолютные */
|
||
.stage{display:flex;gap:10px;align-items:stretch}
|
||
.wrap{flex:1 1 auto;min-width:0;overflow-x:auto;overflow-y:hidden;border:1px solid var(--line);border-radius:8px;background:var(--card);user-select:none;-webkit-user-select:none}
|
||
.board{position:relative;min-width:max(100%,max-content)}
|
||
svg.edges,svg.guides{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}
|
||
svg.guides{z-index:3}
|
||
svg.guides line{stroke:var(--guide);stroke-width:1;stroke-dasharray:4 3}
|
||
.empty{padding:40px 16px;color:var(--muted);text-align:center}
|
||
.lane{position:absolute;left:0;right:0;border-top:1px solid var(--line)}
|
||
.lane:nth-child(even of .lane){background:color-mix(in srgb,var(--soft) 45%,transparent)}
|
||
.lane.folded{background:color-mix(in srgb,var(--soft) 80%,transparent)}
|
||
.lane.target{box-shadow:inset 0 3px 0 var(--accent)}
|
||
.lane.target-after{box-shadow:inset 0 -3px 0 var(--accent)}
|
||
.lane.dragging{opacity:.6}
|
||
.lane-head{position:absolute;left:8px;top:8px;display:flex;align-items:center;gap:4px;z-index:1}
|
||
.fold{width:18px;height:18px;border:0;background:none;color:var(--muted);cursor:pointer;padding:0;border-radius:4px;display:inline-flex;align-items:center;justify-content:center;font-size:0}
|
||
.fold::before{content:"";border:5px solid transparent;border-top:6px solid currentColor;border-bottom:0;transition:transform .15s}
|
||
.fold:hover{background:var(--soft);color:var(--fg)}
|
||
.lane.folded .fold::before{transform:rotate(-90deg)}
|
||
.lane-name{font-size:12.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);font-weight:500;cursor:grab;padding:2px 4px;border-radius:4px;user-select:none;touch-action:none}
|
||
.lane-name:hover{background:var(--soft);color:var(--fg)}
|
||
.lane-count{display:none;font-size:12.5px;color:var(--muted);margin-left:4px}
|
||
.lane.folded .lane-count{display:inline}
|
||
.lane-tools{display:none;gap:2px}
|
||
.lane:hover .lane-tools{display:inline-flex}
|
||
.lane-tools button{font:inherit;font-size:12px;line-height:1;color:var(--muted);background:var(--card);border:1px solid var(--line);border-radius:4px;width:22px;height:22px;cursor:pointer;padding:0}
|
||
.lane-tools button:hover{border-color:var(--accent);color:var(--accent)}
|
||
.lane-tools button:disabled{opacity:.35;cursor:default}
|
||
/* режим «по датам»: колонки по плановым датам окончания, шапка колонки над дорожками; карточки двигаются внутри своей колонки */
|
||
.dcol{position:absolute;top:0;border-left:1px solid var(--line);box-sizing:border-box;pointer-events:none}
|
||
.dcol:first-of-type{border-left:0}
|
||
.dcol.past{background:color-mix(in srgb,var(--p-crit) 5%,transparent)}
|
||
.dcol.today{background:color-mix(in srgb,var(--accent) 7%,transparent)}
|
||
/* полоса дат над доской: липнет под шапкой страницы, по горизонтали двигается вместе с доской (transform по scrollLeft) */
|
||
.dates-bar{position:sticky;top:calc(var(--head-h,0px) + env(safe-area-inset-top,0px));z-index:9;background:var(--bg)}
|
||
.dates-in{overflow:hidden;border:1px solid var(--line);border-bottom:0;border-radius:8px 8px 0 0;background:var(--card);height:36px;box-sizing:border-box}
|
||
.dates-track{position:relative;height:100%}
|
||
.dlabel{position:absolute;top:9px;font-size:12.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--fg);font-weight:600;white-space:nowrap}
|
||
.dlabel .n{font-weight:400;color:var(--muted);margin-left:6px;font-variant-numeric:tabular-nums}
|
||
.dlabel.past{color:var(--p-crit)}
|
||
.dlabel.today{color:var(--accent)}
|
||
.dlabel.none{color:var(--muted);font-weight:500}
|
||
.stage.bydate .wrap{border-top-left-radius:0;border-top-right-radius:0}
|
||
.dsplit{position:absolute;top:0;bottom:0;width:11px;margin-left:-5px;cursor:col-resize;z-index:1}
|
||
.dsplit::after{content:"";position:absolute;left:5px;top:6px;bottom:6px;width:1px;background:var(--line)}
|
||
.dsplit:hover::after,.dsplit.on::after{background:var(--accent);width:3px;left:4px;border-radius:2px}
|
||
.dates-in.resizing{cursor:col-resize}
|
||
/* горизонтальная прокрутка доски, прилипшая к низу окна: полоса всегда видна, скроллбар нарисован явно (на macOS системный прячется) */
|
||
.hscroll{position:sticky;bottom:0;z-index:8;overflow-x:scroll;overflow-y:hidden;height:14px;background:var(--bg);padding-top:2px;box-sizing:border-box;scrollbar-width:thin;scrollbar-color:var(--edge) var(--soft)}
|
||
.hscroll>div{height:1px}
|
||
.hscroll::-webkit-scrollbar{height:12px}
|
||
.hscroll::-webkit-scrollbar-track{background:var(--soft);border-radius:6px}
|
||
.hscroll::-webkit-scrollbar-thumb{background:var(--edge);border-radius:6px;border:2px solid var(--soft)}
|
||
.hscroll::-webkit-scrollbar-thumb:hover{background:var(--accent)}
|
||
/* карточка: текст выделяется, тянуть можно только за рамку (появляется при наведении) и за уголок */
|
||
.card{position:absolute;width:240px;height:120px;box-sizing:border-box;background:var(--card);border:1px solid var(--line);border-radius:6px;padding:12px 14px;display:flex;flex-direction:column;gap:4px;overflow:hidden;cursor:default;touch-action:none}
|
||
.card:hover{border-color:var(--accent)}
|
||
.card.out{border-style:dashed;opacity:.7}
|
||
.card.crit{box-shadow:0 0 0 2px var(--guide)}
|
||
.card.focus{box-shadow:0 0 0 2px var(--accent)}
|
||
.card.drag{cursor:grabbing;border-color:var(--accent);box-shadow:0 6px 18px rgba(0,0,0,.18);z-index:2}
|
||
.card .title,.card .state,.card .who,.card .meta,.card .key,.card .sprint-tag{user-select:text;-webkit-user-select:text;cursor:text;touch-action:auto}
|
||
.card .key{cursor:pointer}
|
||
.card .meta{cursor:default}
|
||
.card .grip{position:absolute;background:var(--accent);opacity:0;cursor:grab;z-index:1;transition:opacity .12s}
|
||
.card .grip.t{left:0;right:0;top:0;height:8px;border-radius:5px 5px 0 0}
|
||
.card .grip.b{left:0;right:14px;bottom:0;height:8px;border-radius:0 0 0 5px}
|
||
.card .grip.l{left:0;top:0;bottom:0;width:8px;border-radius:5px 0 0 5px}
|
||
.card .grip.r{right:0;top:0;bottom:14px;width:8px;border-radius:0 5px 0 0}
|
||
.card:hover .grip,.card.drag .grip{opacity:.85}
|
||
.card.drag .grip{cursor:grabbing}
|
||
.card .rsz{position:absolute;right:0;bottom:0;width:14px;height:14px;cursor:nwse-resize;background:linear-gradient(135deg,transparent 50%,var(--line) 50%);border-bottom-right-radius:5px;z-index:2}
|
||
.card:hover .rsz{background:linear-gradient(135deg,transparent 50%,var(--accent) 50%)}
|
||
.head{display:flex;align-items:center;justify-content:space-between;gap:6px;min-width:0}
|
||
.key{font-family:var(--font-mono);font-size:13.5px;font-weight:500;color:var(--accent);text-decoration:none;flex:0 0 auto}
|
||
.sprint-tag{font-size:11px;line-height:1;padding:4px 7px;border-radius:10px;background:var(--soft);color:var(--muted);max-width:55%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:0 1 auto}
|
||
.sprint-tag.sel{background:var(--accent);color:var(--accent-fg)}
|
||
.sprint-tag.none{background:transparent;border:1px dashed var(--line)}
|
||
.title{font-size:14.5px;line-height:19px;min-width:0;flex:1 1 auto;overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}
|
||
.meta{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--muted);white-space:nowrap;flex:0 0 auto}
|
||
.kind{display:inline-flex;align-items:center;gap:4px}
|
||
.kind i{width:8px;height:8px;border-radius:2px;display:inline-block}
|
||
.prio{font-weight:600}
|
||
/* состояние задачи (статус, срок, исполнитель) — отдельный блок под чертой, основным цветом: это главное после названия */
|
||
.state,.who{font-size:13px;flex:0 0 auto;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--fg)}
|
||
.state{display:flex;align-items:center;gap:6px;margin-top:4px;padding-top:7px;border-top:1px solid var(--line)}
|
||
.status{font-size:12.5px;line-height:1;padding:4px 7px;border-radius:10px;background:var(--soft);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.due{font-variant-numeric:tabular-nums;font-weight:600;flex:0 0 auto}
|
||
.who::before{content:"";display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--accent);margin-right:5px;vertical-align:1px}
|
||
.who.none{color:var(--muted);font-style:italic}
|
||
.who.none::before{background:transparent;border:1px solid var(--muted)}
|
||
|
||
/* 7. Панель проблем справа от доски, липнет под шапкой и прокручивается внутри себя */
|
||
.drawer{flex:0 0 340px;max-width:40%;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:10px 12px;overflow:auto;font-size:14px;box-sizing:border-box;align-self:flex-start;position:sticky;top:calc(var(--head-h,0px) + 8px);max-height:calc(100vh - var(--head-h,0px) - 24px)}
|
||
.drawer-head{display:flex;justify-content:space-between;align-items:center;gap:8px}
|
||
.drawer-head b{font-weight:600}
|
||
.drawer h4{font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);font-weight:500;margin:12px 0 4px}
|
||
.drawer ul{margin:0;padding-left:18px}
|
||
.drawer li{margin:3px 0}
|
||
.drawer .k{font-family:var(--font-mono);font-size:13px;color:var(--accent);text-decoration:none}
|
||
.drawer .ok{color:var(--k-test);margin:8px 0 0}
|
||
.drawer .why{color:var(--muted)}
|
||
|
||
/* 8. Экспорт JSON и таблица связей (включаются из меню «⋯») */
|
||
.export{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:10px 12px;margin:0 0 8px;display:flex;flex-direction:column;gap:8px}
|
||
.export textarea{font-family:var(--font-mono);font-size:11.5px;width:100%;box-sizing:border-box;height:180px;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--fg);padding:8px;resize:vertical}
|
||
details{margin-top:12px}
|
||
summary{cursor:pointer;color:var(--muted);font-size:14px}
|
||
.tbl{max-width:100%;overflow-x:auto;margin-top:8px}
|
||
table{border-collapse:collapse;font-size:14px;min-width:520px}
|
||
th,td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--line);vertical-align:top}
|
||
th{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);font-weight:500}
|
||
td.k{font-family:var(--font-mono);font-size:12px;white-space:nowrap}
|
||
|
||
/* 9. Движение и узкие экраны */
|
||
@media (prefers-reduced-motion:no-preference){.card{transition:border-color .15s}}
|
||
@media (prefers-reduced-motion:reduce){.card .grip,.fold::before{transition:none}}
|
||
@media (max-width:900px){.stage{flex-direction:column}.drawer{flex:none;max-width:100%;max-height:none;position:static}}
|
||
@media (max-width:640px){.sticky{position:static}.dates-bar{position:static}.file-name{max-width:100%}}
|
||
</style>
|
||
|
||
<div class="sticky">
|
||
<div class="top">
|
||
<h1>Спринт 2 линк</h1>
|
||
<span class="src"><span class="file-name" id="fileName"></span><label class="link" for="file" title="CSV из фильтра Jira или JSON, выгруженный с этой страницы. Расстановка карточек, дорожки и фильтры сохраняются по ключам задач и после замены файла остаются">заменить…</label><input type="file" id="file" accept=".csv,.json,text/csv,application/json" hidden></span>
|
||
<span class="stats" id="stats"></span>
|
||
<span class="spacer"></span>
|
||
<button class="btn" id="probBtn" type="button" aria-pressed="false" title="Сроки блокеров, задачи без исполнителя, передачи между людьми">Проблемы <span class="n zero" id="probCount">0</span></button>
|
||
<div class="dd" id="more">
|
||
<button type="button" class="btn more-btn" aria-haspopup="menu" aria-expanded="false" title="Ещё">⋯</button>
|
||
<div class="dd-menu menu" hidden>
|
||
<button type="button" id="exportJson">Экспорт JSON</button>
|
||
<button type="button" id="tblToggle" aria-pressed="false">Таблица связей</button>
|
||
<button type="button" id="snap" aria-pressed="true">Привязка к сетке</button>
|
||
<button type="button" id="theme">Тема: системная</button>
|
||
<hr>
|
||
<span id="resetWrap"><button type="button" class="danger" id="reset">Сбросить раскладку</button></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="bar">
|
||
<span id="sprint"></span><span id="who"></span>
|
||
<button class="btn" id="neighbors" type="button" aria-pressed="false" title="Добавить к отобранным задачам их соседей по связям из любого спринта, любого исполнителя и вида работы. Соседи рисуются пунктиром и считаются отдельно">+ соседи по связям</button>
|
||
<button class="btn" id="onlyHand" type="button" aria-pressed="false" title="Оставить только стрелки, где задачу ждёт другой человек. Карточки не убирает — для этого «скрыть без связей»">⇄ передачи</button>
|
||
<button class="btn" id="onlyLinked" type="button" aria-pressed="false" title="Убрать с доски карточки, у которых не осталось ни одной видимой стрелки">скрыть без связей</button>
|
||
<button class="btn" id="critPath" type="button" aria-pressed="false" title="Самая длинная цепочка блокировок по плановым датам окончания">⚑ критический путь</button>
|
||
<button class="btn" id="byDate" type="button" aria-pressed="false" title="Разложить карточки по колонкам плановых дат окончания. У этого режима своя расстановка: карточку можно двигать внутри её колонки; обычная расстановка не меняется и вернётся, когда режим выключен">▥ по датам</button>
|
||
</div>
|
||
<div class="notice" id="notice" hidden></div>
|
||
<div class="legend" id="legend">
|
||
<button type="button" class="lg" data-kind="decision"><span class="sw" style="background:var(--k-decision)"></span>решение</button>
|
||
<button type="button" class="lg" data-kind="analytics"><span class="sw" style="background:var(--k-analytics)"></span>аналитика / актуализация</button>
|
||
<button type="button" class="lg" data-kind="dev"><span class="sw" style="background:var(--k-dev)"></span>разработка</button>
|
||
<button type="button" class="lg" data-kind="ui"><span class="sw" style="background:var(--k-ui)"></span>UI</button>
|
||
<button type="button" class="lg" data-kind="test"><span class="sw" style="background:var(--k-test)"></span>тестирование</button>
|
||
<button type="button" class="lg" data-kind="org"><span class="sw" style="background:var(--k-org)"></span>организационная</button>
|
||
<span class="sep"></span>
|
||
<button type="button" class="lg" data-edge="block"><span class="ln"></span>блокирует начало → заблокирована</button>
|
||
<button type="button" class="lg" data-edge="rel"><span class="ln dash"></span>связанные</button>
|
||
<span><span class="ln hand"></span>передача другому исполнителю</span>
|
||
<span><span class="ln crit"></span>критический путь</span>
|
||
<span><span class="out-sw"></span>сосед по связям, сам под фильтры не попадает</span>
|
||
<span class="hint">щелчок по виду работы или типу связи скрывает их</span>
|
||
</div>
|
||
</div>
|
||
<div class="export" id="exportBox" hidden>
|
||
<div class="row"><span class="hint">JSON доски: все задачи файла, связи, раскладка, дорожки и фильтры. Загружается обратно ссылкой «заменить…». Если файл не скачался, скопируйте текст.</span><button class="btn" id="copyJson" type="button">Копировать</button><button class="btn" id="closeJson" type="button">Закрыть</button></div>
|
||
<textarea id="jsonText" readonly spellcheck="false"></textarea>
|
||
</div>
|
||
<div class="dates-bar" id="datesBar" hidden><div class="dates-in" id="datesIn"><div class="dates-track" id="datesTrack"></div></div></div>
|
||
<div class="stage" id="stage">
|
||
<div class="wrap" id="wrap"><div class="board" id="board"><svg class="edges" id="edges"></svg><svg class="guides" id="guides"></svg></div></div>
|
||
<aside class="drawer" id="problems" hidden><div class="drawer-head"><b>Проблемы на доске</b><button type="button" class="btn sm" id="probClose">Закрыть</button></div><div id="probBody"></div></aside>
|
||
</div>
|
||
<div class="hscroll" id="hscroll" hidden><div id="hscrollIn"></div></div>
|
||
<details id="tblBox" hidden><summary>Таблица связей</summary><div class="tbl"><table><thead><tr><th>Блокирует</th><th>Тип</th><th>Заблокирована</th></tr></thead><tbody id="tblBody"></tbody></table></div></details>
|
||
|
||
<script>
|
||
"use strict";
|
||
/* Разделы: 1 справочники и константы · 2 состояние и хранилище · 3 разбор файлов (CSV Jira, JSON доски) · 4 выборка и анализ
|
||
(фильтры, фокус на исполнителе, критический путь, проблемы) · 5 раскладка и отрисовка доски · 6 панель управления · 7 экспорт и запуск */
|
||
|
||
// ===== 1. Справочники и константы
|
||
const DEFAULT_CSV = "Ключ проблемы,Тема,Статус,Пользовательское поле (Связи Фичи),Приоритет,Исполнитель,Тип задачи,Пользовательское поле (Плановая дата окончания),Метки,Метки,Метки,Метки,Внешняя ссылка на проблему (ASUPR_Блокер_Начала),Внешняя ссылка на проблему (ASUPR_Блокер_Начала),Внешняя ссылка на проблему (ASUPR_Связанные),Внешняя ссылка на проблему (ASUPR_Связанные),Внешняя ссылка на проблему (Cloners),Спринт\nAURORA-1365,Гранулярность сопоставления ВГО — агрегат или строки 1:1,Ожидает анализа,AURORA-1357,Критичный,Lysenko-IS@rshbavt.ru,Задача,15/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-decision,kind-decision,AURORA-1536,,,,,AWPCA_Sprint_1_Anal\nAURORA-1373,Шахматка по ТЗ 6.6,Анализ,AURORA-1357,Критичный,Lysenko-IS@rshbavt.ru,Задача,15/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-in-progress,kind-analytics,AURORA-1537,,,,,AWPCA_Sprint_1_Anal\nAURORA-1480,Модернизировать концепцию корректировок ,Анализ,AURORA-1358,Критичный,Starovoytov-AAlek@rshbavt.ru,Задача,16/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-in-progress,kind-analytics,AURORA-1395,AURORA-1396,,,AURORA-1366,AWPCA_Sprint_1_Anal\nAURORA-1536,\"Автосопоставление ВГО §5: правила elimination_rule как данные, версия правил, пары, расхождения\",Ожидание разработки,AURORA-1357,Критичный,Potapova-AIg,Задача,19/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-dev,AURORA-1537,,,,,AWPCA_Sprint_2_VGO\nAURORA-1395,Форма корректировок ББ,Анализ,AURORA-1358,Критичный,Starovoytov-AAlek@rshbavt.ru,Задача,23/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-in-progress,kind-analytics,AURORA-1540,,,,,AWPCA_Sprint_2_VGO\nAURORA-1537,\"Шахматка §6: API матрицы по счёту/субсчёту, сводная таблица по статьям, переход в свод, экспорт xlsx\",Ожидание разработки,AURORA-1357,Критичный,Potapova-AIg,Задача,27/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-dev,AURORA-1538,AURORA-1539,,,,AWPCA_Sprint_2_VGO\nAURORA-1540,\"Корректировки §8: модель correction, контроль 5 %, статусы DRAFT/APPLIED/REVOKED, импорт/экспорт xlsx, аудит ADJUSTMENT\",Ожидание разработки,AURORA-1358,Критичный,,Задача,02/11/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-dev,,,,,,\nAURORA-1388,Блокировка шага до сопоставления контрагентов без ИНН,Ожидает анализа,AURORA-1357,Высокий,Lysenko-IS@rshbavt.ru,Задача,15/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-decision,kind-decision,AURORA-1536,,,,,AWPCA_Sprint_2_VGO\nAURORA-1445,\" Экран «Проверка распознавания»: формы ББ/ОФР и распарсенная ОСВ, правка и подтверждение\",Разработка,AURORA-1353,Высокий,Stepanov-MAl,Задача,20/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-in-progress,kind-ui,,,AURORA-1535,,,AWPCA_Sprint_2_VGO\nAURORA-1410,Справочники: экраны ГК и организаций по макету,Разработка,AURORA-1351,Высокий,Golov-DS@rshbavt.ru,Задача,20/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-in-progress,kind-ui,,,,,,AWPCA_Sprint_2_VGO\nAURORA-1444,\"Экраны: панель «Первичные документы», окно загрузки, чек-лист\",Разработка,AURORA-1353,Высокий,Stepanov-MAl,Задача,20/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-in-progress,kind-ui,,,AURORA-1534,AURORA-1535,,AWPCA_Sprint_2_VGO\nAURORA-1532,\"Сводная ОСВ: UI по макету [30], [32]\",Разработка,AURORA-1356,Высокий,Stepanov-MAl,Задача,20/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-in-progress,kind-ui,,,AURORA-1533,,,AWPCA_Sprint_2_VGO\nAURORA-1534,\"Загрузка и разбор ОСВ: тестирование на стенде (xlsx/xls/xlsb, дедуп, замена, автоподтверждение)\",Ожидание разработки,AURORA-1353,Высокий,Salmin-MP@rshbavt.ru,Задача,20/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-test,,,,,,AWPCA_Sprint_2_VGO\nAURORA-1396,Форма корректировок ОФР,Ожидает анализа,AURORA-1358,Высокий,Starovoytov-AAlek@rshbavt.ru,Задача,23/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-analytics,AURORA-1540,,,,,AWPCA_Sprint_1_Anal\nAURORA-1367,\"Статусная модель КФО: «Корректировка», маршрут, «Отклонено»/«Архив»\",Ожидает анализа,AURORA-1352,Высокий,Starovoytov-AAlek@rshbavt.ru,Задача,23/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-partial,kind-decision,,,,,,AWPCA_Sprint_1_Anal\nAURORA-1533,\"Сводная ОСВ: тестирование (контрольные цифры qa-guide §7.2, пробелы, ручное сопоставление)\",Ожидание разработки,AURORA-1356,Высокий,Salmin-MP@rshbavt.ru,Задача,24/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-test,,,,,,AWPCA_Sprint_2_VGO\nAURORA-1535,\"Загрузка и распознавание ББ/ОФР: тестирование (OCR-пары fixtures/ocr, проверки §7, акцепт, подтверждение)\",Ожидание разработки,AURORA-1353,Высокий,Salmin-MP@rshbavt.ru,Задача,27/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-test,,,,,,AWPCA_Sprint_2_VGO\nAURORA-1571,Перенос фронта arm-web в arm-core/web и сборка фронта в образ arm-core,Ожидание разработки,AURORA-1350,Высокий,,Задача,30/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-dev,,,,,,\nAURORA-1538,Шахматка: UI по макету [31-01],Ожидание разработки,AURORA-1357,Высокий,Stepanov-MAl,Задача,30/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-ui,,,,,,\nAURORA-1539,Шахматка и ВГО: тестирование,Ожидание разработки,AURORA-1357,Высокий,Salmin-MP@rshbavt.ru,Задача,02/11/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-test,,,,,,\nAURORA-1369,Гудвилл: база расчёта и ветка «без гудвилла»,Ожидает анализа,AURORA-1359,Высокий,,Задача,03/11/2026 00:00,actualization-2026-10-10,AWPCA,fact-decision,kind-decision,,,,,,\nAURORA-1404,Аутентификация через РАИСА/SSO,Ожидает анализа,AURORA-1529,Высокий,,Задача,06/11/2026 00:00,actualization-2026-10-10,AWPCA,fact-decision,kind-decision,,,,,,\nAURORA-1541,Корректировки: UI форм ББ и ОФР по макету [31-02],Ожидание разработки,AURORA-1358,Высокий,Stepanov-MAl,Задача,06/11/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-ui,,,,,,\nAURORA-1366,\"Ролевая модель: свести ТЗ, аналитику и макет в одну\",Ожидает анализа,AURORA-1529,Высокий,Starovoytov-AAlek@rshbavt.ru,Задача,06/11/2026 00:00,actualization-2026-10-10,AWPCA,fact-partial,kind-analytics,,,,,,AWPCA_Sprint_1_Anal\nAURORA-1543,\"Гудвилл §8: расчёт по правилу № 7, таблица goodwill, ветка «без гудвилла», переключатель на вкладке\",Ожидание разработки,AURORA-1359,Высокий,,Задача,09/11/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-dev,,,,,,\nAURORA-1394,Контрольные пороги по итогам консолидации,Ожидает анализа,AURORA-1360,Высокий,,Задача,10/11/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-analytics,,,,,,\nAURORA-1374,Консолидированный ОФР (Прил. 4),Ожидает анализа,AURORA-1360,Высокий,,Задача,10/11/2026 00:00,actualization-2026-10-10,AWPCA,fact-decision,kind-decision,,,,,,\nAURORA-1375,Результат КФО и форма Прил. 10,Ожидает анализа,AURORA-1360,Высокий,,Задача,10/11/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-analytics,,,,,,\nAURORA-1542,Расхождения и корректировки: тестирование,Ожидание разработки,AURORA-1358,Высокий,Salmin-MP@rshbavt.ru,Задача,10/11/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-test,,,,,,\nAURORA-1574,\"Фикстуры: реальные xlsb и Р7 от заказчика, ОСВ расширенного подхода, формы без колонки кодов\",Ожидание разработки,AURORA-1354,Средний,Epifantsev-AA@rshbavt.ru,Задача,09/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-org,AURORA-1534,,,,,AWPCA_Sprint_2_VGO\nAURORA-1380,Парсинг ББ/ОФР из Excel/Р7 и архитектура сервисов,Ожидает анализа,AURORA-1354,Средний,Lysenko-IS@rshbavt.ru,Задача,20/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-docs,kind-docs,,,AURORA-1583,,,AWPCA_Sprint_1_Anal\nAURORA-1378,Замена и удаление первичного документа,Ожидает анализа,AURORA-1353,Средний,Starovoytov-AAlek@rshbavt.ru,Задача,20/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-docs,kind-docs,,,AURORA-1534,,,AWPCA_Sprint_2_VGO\nAURORA-1379,Чек-лист документов: блокировка и формат,Ожидает анализа,AURORA-1353,Средний,Starovoytov-AAlek@rshbavt.ru,Задача,20/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-docs,kind-docs,,,AURORA-1534,,,AWPCA_Sprint_2_VGO\nAURORA-1582,\"Актуализировать страницу «Справочник ГК и организации» (DOC-221280) по Срезу 1: КПП, ГСК+ГСЗ одновременно, импорт ЕЦП §13, code/crm_id\",Ожидает анализа,AURORA-1351,Средний,Starovoytov-AAlek@rshbavt.ru,Задача,20/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-docs,,,,,,AWPCA_Sprint_2_VGO\nAURORA-1371,Сводная ОСВ: свёртка до счёт–субсчёт–контрагент,Ожидает анализа,AURORA-1356,Средний,Lysenko-IS@rshbavt.ru,Задача,20/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-docs,kind-docs,,,AURORA-1533,,,AWPCA_Sprint_2_VGO\nAURORA-1372,Признак контрагента: сопоставление вместо переопределения,Ожидает анализа,AURORA-1356,Средний,Lysenko-IS@rshbavt.ru,Задача,20/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-docs,kind-docs,,,AURORA-1533,,,AWPCA_Sprint_2_VGO\nAURORA-1370,Единая статусная модель документа,Ожидает анализа,AURORA-1353,Средний,Starovoytov-AAlek@rshbavt.ru,Задача,20/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-docs,kind-docs,,,,,,AWPCA_Sprint_1_Anal\nAURORA-1381,Акцепт расхождений и комментарии к отчётности,Ожидает анализа,AURORA-1355,Средний,Starovoytov-AAlek@rshbavt.ru,Задача,20/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-docs,kind-docs,,,,,,AWPCA_Sprint_1_Anal\nAURORA-1446,\"Комментарии к объектам (документ, ячейка шахматки, строка КФО)\",Ожидание разработки,AURORA-1353,Средний,,Задача,22/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-partial,kind-dev,,,AURORA-1537,AURORA-1445,,AWPCA_Sprint_2_VGO\nAURORA-1442,\"Жизненный цикл документа: проверки периода/контрагента, подтверждение, повторная обработка, замена и удаление\",Ожидание разработки,AURORA-1353,Средний,,Задача,23/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-partial,kind-dev,,,AURORA-1370,,,AWPCA_Sprint_2_VGO\nAURORA-1376,Автопересчёт консолидации,Ожидает анализа,AURORA-1352,Средний,,Задача,10/11/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-analytics,,,,,,\nAURORA-1383,Данные предыдущих периодов,Ожидает анализа,AURORA-1360,Средний,,Задача,10/11/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-analytics,,,,,,\nAURORA-1218,Реализовать миграции данных. Этап 1,Готово к тестированию,AURORA-1350,Низкий,Stepanov-MAl,Задача,31/08/2026 00:00,actualization-2026-10-10,AWPCA,fact-done,kind-dev,,,,,,\nAURORA-1553,Логические проверки ББ/ОФР §7 в ядре и акцепт нарушений,Готово к тестированию,AURORA-1355,Низкий,,Задача,31/08/2026 00:00,actualization-2026-10-10,AWPCA,fact-done,kind-dev,,,,,,\nAURORA-1409,\"Справочники: CRUD ГК, ЮЛ/ИП, ГСК/ГСЗ и состав групп с историей\",Готово к тестированию,AURORA-1351,Низкий,,Задача,31/08/2026 00:00,actualization-2026-10-10,AWPCA,fact-done,kind-dev,,,,,,\nAURORA-1411,Алиасы контрагентов: справочник и разрешение конфликтов,Готово к тестированию,AURORA-1351,Низкий,,Задача,31/08/2026 00:00,actualization-2026-10-10,AWPCA,fact-done,kind-dev,,,,,,\nAURORA-1450,scan-gateway: передача документа в ИС Сканирование и получение результата,Готово к тестированию,AURORA-1354,Низкий,,Задача,31/08/2026 00:00,actualization-2026-10-10,AWPCA,fact-done,kind-dev,,,,,,\nAURORA-1451,scan-gateway: нормализация JSON ИС Сканирование во внутреннюю схему формы,Готово к тестированию,AURORA-1354,Низкий,,Задача,31/08/2026 00:00,actualization-2026-10-10,AWPCA,fact-done,kind-dev,,,,,,\nAURORA-1448,ББ и ОФР из Excel / Р7 в единую схему формы,Готово к тестированию,AURORA-1354,Низкий,,Задача,31/08/2026 00:00,actualization-2026-10-10,AWPCA,fact-done,kind-dev,,,,,,\nAURORA-1531,\"Сводная ОСВ: API, склейка периодов, признак контрагента, ручное сопоставление, экспорт xlsx\",Готово к тестированию,AURORA-1356,Низкий,,Задача,09/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-done,kind-dev,,,,,,\nAURORA-1413,\"Консолидация: создание, версии, периметр, привязка документов\",Готово к тестированию,AURORA-1352,Низкий,,Задача,09/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-done,kind-dev,,,,,,\nAURORA-1441,\"Маппер результатов распознавания в БД (ОСВ и формы), порог 100 % и метрики качества\",Готово к тестированию,AURORA-1353,Низкий,,Задача,09/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-done,kind-dev,,,,,,\nAURORA-1440,Оркестрация распознавания через recognition_job (outbox / inbox NATS),Готово к тестированию,AURORA-1353,Низкий,,Задача,09/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-done,kind-dev,,,,,,\nAURORA-1439,\"Загрузка первичных документов: stored_file, S3, дедуп, определение типа\",Готово к тестированию,AURORA-1353,Низкий,,Задача,09/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-done,kind-dev,,,,,,\nAURORA-1443,Чек-лист комплектности документов версии,Готово к тестированию,AURORA-1353,Низкий,,Задача,09/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-done,kind-dev,,,,,,\nAURORA-1447,tabular-parser: табличная часть ОСВ — дерево строк по уровням группировки и контрольные суммы,Готово к тестированию,AURORA-1354,Низкий,,Задача,09/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-done,kind-dev,,,,,,\nAURORA-1415,Блокировка «взять в работу» и перехват прав,Готово к тестированию,AURORA-1352,Низкий,,Задача,09/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-done,kind-dev,,,,,,\nAURORA-1449,\"tabular-parser: поддержка xls, xlsb, Р7 и определение формата по содержимому\",Готово к тестированию,AURORA-1354,Низкий,,Задача,09/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-done,kind-dev,,,,,,\nAURORA-1583,\"Актуализировать FNR01/FNR02 (DOC-037740, DOC-221275) по контракту v1.2 и горячим папкам\",Ожидает анализа,AURORA-1354,Низкий,Lysenko-IS@rshbavt.ru,Задача,20/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-docs,,,,,,AWPCA_Sprint_2_VGO\nAURORA-1585,Актуализировать «Пользовательский сценарий» (DOC-037736) и «Пользовательские сценарии подробно» (DOC-221274) по пути пользователя 10-user-journey.md,Ожидает анализа,AURORA-1364,Низкий,Starovoytov-AAlek@rshbavt.ru,Задача,20/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-docs,,,,,,\nAURORA-1392,Базовый и расширенный подход,Ожидает анализа,AURORA-1352,Низкий,,Задача,10/11/2026 00:00,actualization-2026-10-10,AWPCA,fact-partial,kind-analytics,,,,,,\nAURORA-1403,Расшифровки участника — входят ли в этап,Ожидает анализа,AURORA-1360,Низкий,,Задача,10/11/2026 00:00,actualization-2026-10-10,AWPCA,fact-decision,kind-decision,,,,,,\n";
|
||
const DEFAULT_NAME = "Анализ спринтов (СУЖЦ ПО) 2026-10-11 13:11 (встроенная выгрузка)";
|
||
const JIRA = "https://sdlc.go.rshbank.ru/jira";
|
||
// ключ эпика → название дорожки; строки типа «Фича» в файле дополняют список
|
||
const EPICS = {"AURORA-1350": "Архитектура и инфраструктура", "AURORA-1351": "Справочники ГК / ГСК / ГСЗ / ЮЛ", "AURORA-1352": "Консолидация: карточка, версии, статусы", "AURORA-1353": "Загрузка документов и распознавание", "AURORA-1354": "Сервисы распознавания и парсинга", "AURORA-1355": "Логические проверки отчётности", "AURORA-1356": "Сводная ОСВ", "AURORA-1357": "Сопоставление ВГО и Шахматка", "AURORA-1358": "Расхождения и корректировки", "AURORA-1359": "Гудвилл", "AURORA-1360": "Формирование КФО", "AURORA-1361": "Экспорт", "AURORA-1362": "Журнал аудита", "AURORA-1363": "Единое хранилище (ЕХФД)", "AURORA-1364": "Аналитика", "AURORA-1529": "Роли, доступ и аутентификация (SSO)", "AURORA-1530": "Приёмка и передача системы (тестирование КА, ПСИ)"};
|
||
const KIND_LABELS = {"kind-decision": "решение", "kind-analytics": "аналитика", "kind-docs": "актуализация", "kind-dev": "разработка", "kind-ui": "UI", "kind-test": "тестирование", "kind-org": "организационная"}; // метка Jira → вид работы
|
||
const KIND = {"решение": "decision", "аналитика": "analytics", "актуализация": "analytics", "разработка": "dev", "UI": "ui", "тестирование": "test", "организационная": "org"}; // вид работы → цвет и фильтр легенды
|
||
const PRIO = {"Критичный": "crit", "Наивысший": "crit", "Высокий": "high", "Средний": "mid", "Низкий": "low", "Самый низкий": "low"};
|
||
const LINK_TYPES = {"ASUPR_Блокер_Начала": "блокирует начало", "ASUPR_Блокер_Завершения": "блокирует завершение", "ASUPR_Связанные": "связанные"};
|
||
const RELATED = "связанные"; // единственный ненаправленный тип связи
|
||
const NO_EPIC = "Без эпика", NOT_IN_FILE = "Нет в файле"; // служебные дорожки, всегда внизу
|
||
const KEY_RE = /^[A-Z][A-Z0-9]+-\d+$/;
|
||
// геометрия доски, px: карточка, колонка слоя, отступы, свёрнутая дорожка, сетка и порог примагничивания
|
||
const CW = 240, COLW = 296, LEFT = 24, TOP = 40, MAXROWS = 4, FOLD_H = 36, GRID = 8, SNAP = 6, MINW = 160, MINH = 80, TITLE_LH = 19;
|
||
const NS = "http://www.w3.org/2000/svg";
|
||
const ARROWS = `<defs><marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z" fill="var(--edge-strong)"/></marker><marker id="arrHand" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z" fill="var(--guide)"/></marker></defs>`;
|
||
|
||
const $ = id => document.getElementById(id);
|
||
const board = $("board"), edgesSvg = $("edges"), guidesSvg = $("guides");
|
||
const esc = s => String(s ?? "").replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||
const plural = (n, one, few, many) => `${n} ${n % 10 === 1 && n % 100 !== 11 ? one : n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? few : many}`;
|
||
const fmtDate = d => { const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(d); return m ? `${m[3]}.${m[2]}` : d; };
|
||
const dayDiff = (a, b) => (!a || !b) ? null : Math.round((new Date(b) - new Date(a)) / 86400000);
|
||
const notStarted = status => /ожида|backlog|бэклог|к выполнению|to do|open/i.test(status || "");
|
||
const unassigned = w => !w || /^(не назначен|unassigned)$/i.test(w);
|
||
const whoKey = w => unassigned(w) ? "" : w.trim();
|
||
// «Степанов Максим Александрович» → «Степанов М. А.»; логины вида Stepanov-MAl остаются как есть
|
||
function shortName(w) {
|
||
if (unassigned(w)) return "не назначен";
|
||
const parts = w.trim().split(/\s+/); if (parts.length < 2) return w;
|
||
return parts[0] + " " + parts.slice(1, 3).map(x => x[0].toUpperCase() + ".").join(" ");
|
||
}
|
||
// даты Jira в разных локалях → ГГГГ-ММ-ДД, чтобы сравнивать строками
|
||
function sortableDate(d) {
|
||
let m = /^(\d{4})-(\d{2})-(\d{2})/.exec(d); if (m) return d.slice(0, 10);
|
||
m = /^(\d{1,2})[./](\d{1,2})[./](\d{4})/.exec(d); if (m) return `${m[3]}-${m[2].padStart(2, "0")}-${m[1].padStart(2, "0")}`;
|
||
m = /^(\d{1,2})\/([A-Za-zА-Яа-я]{3})\/(\d{2})(\d{2})?/.exec(d);
|
||
if (m) {
|
||
const mon = { jan: "01", feb: "02", mar: "03", apr: "04", may: "05", jun: "06", jul: "07", aug: "08", sep: "09", oct: "10", nov: "11", dec: "12", янв: "01", фев: "02", мар: "03", апр: "04", май: "05", мая: "05", июн: "06", июл: "07", авг: "08", сен: "09", окт: "10", ноя: "11", дек: "12" }[m[2].toLowerCase()];
|
||
if (mon) return `${m[4] ? m[3] + m[4] : "20" + m[3]}-${mon}-${m[1].padStart(2, "0")}`;
|
||
}
|
||
return d;
|
||
}
|
||
|
||
// ===== 2. Состояние и хранилище
|
||
// MODEL — загруженный файл: nodes {key,title,kind,prio,due,status,epic,sprint,who,stub}, links {src,dst,type}, sprints {имя: число}, fileName, problems, hasSprint, missing
|
||
let MODEL = null;
|
||
// фильтры и переключатели. Три слоя: фильтры задач (sprints, who, hideKind — пересечение, пустое множество = без фильтра) и связей
|
||
// (hideEdge, onlyHand — только стрелки); контекст neighbors — расширение соседями по связям; onlyLinked убирает карточки без видимых стрелок;
|
||
// crit — подсветка, byDate — раскладка
|
||
const S = { neighbors: false, onlyLinked: false, onlyHand: false, crit: false, byDate: false, snap: true, sprints: new Set(), who: new Set(), hideKind: new Set(), hideEdge: new Set() };
|
||
// VIS — текущая выборка: nodes (+dim, focus, crit), links (+hand, crit), lanes [{name, keys}], removed {…}, problems
|
||
let VIS = { nodes: [], links: [], lanes: [], removed: {}, problems: null };
|
||
// раскладка: rel[key] = {x, ry, w, h, auto, other} относительно дорожки; pos[key] — абсолютно на доске (нет у карточек свёрнутых дорожек)
|
||
let rel = {}, pos = {}, laneOf = {}, lanesGeom = [], cardEls = {}, baseW = 0, dateEls = [], colBounds = {}; // colBounds[key] = {x0, x1} колонки даты (только в режиме по датам)
|
||
|
||
// localStorage: общие настройки «graph-<имя>», настройки доски «graph-<имя>:board» (раскладка, дорожки, фильтры). Ключ не зависит от имени
|
||
// файла: карточки адресуются ключами Jira, поэтому расстановка переживает замену CSV новой выгрузкой. Чтение и запись защищены от приватного режима
|
||
const store = {
|
||
get(k, d) { try { const v = localStorage.getItem(k); return v === null ? d : v; } catch (e) { return d; } },
|
||
set(k, v) { try { localStorage.setItem(k, v); } catch (e) {} },
|
||
del(k) { try { localStorage.removeItem(k); } catch (e) {} },
|
||
json(k, d) { try { const v = JSON.parse(this.get(k, "null")); return v === null ? d : v; } catch (e) { return d; } },
|
||
};
|
||
const pref = name => "graph-" + name;
|
||
const BOARD = "board", BOARD_KEYS = ["pos", "lanes", "fold", "sprint", "who"];
|
||
const posKey = () => S.byDate ? "posdate" : "pos"; // у обычной раскладки и раскладки «по датам» свои хранилища
|
||
const fileKey = name => "graph-" + name + ":" + BOARD;
|
||
// до 11.10.2026 настройки лежали под именем файла («graph-pos:<файл>») и терялись при каждой новой выгрузке; при первом запуске новой версии
|
||
// переносим на общий ключ набор того файла, где расставлено больше всего карточек (при равенстве — не встроенного примера)
|
||
function migrateStore() {
|
||
if (store.get(fileKey("pos"), null) !== null) return;
|
||
let names = [];
|
||
try { for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k && k.startsWith("graph-pos:") && !k.endsWith(":" + BOARD)) names.push(k.slice("graph-pos:".length)); } } catch (e) { return; }
|
||
if (!names.length) return;
|
||
const size = n => Object.keys(store.json("graph-pos:" + n, {}) || {}).length;
|
||
names.sort((a, b) => size(b) - size(a) || (a === DEFAULT_NAME) - (b === DEFAULT_NAME));
|
||
BOARD_KEYS.forEach(k => { const v = store.get("graph-" + k + ":" + names[0], null); if (v !== null) store.set(fileKey(k), v); });
|
||
}
|
||
const strList = v => Array.isArray(v) ? v.filter(s => typeof s === "string") : [];
|
||
const laneOrder = () => strList(store.json(fileKey("lanes"), []));
|
||
const foldedSet = () => new Set(strList(store.json(fileKey("fold"), [])));
|
||
// сохраняем поверх уже записанного: карточки, скрытые текущим фильтром, не теряют свои места
|
||
// в режиме «по датам» x хранится относительно левой границы колонки даты: при сдвиге границ карточки едут вместе с колонкой
|
||
function saveLayout() {
|
||
const out = store.json(fileKey(posKey()), {}) || {};
|
||
Object.entries(rel).forEach(([k, r]) => { const x = r.x - (colBounds[k] ? colBounds[k].x0 : 0); out[k] = r.auto ? { x, ry: r.ry, w: r.w } : { x, ry: r.ry, w: r.w, h: r.h }; });
|
||
store.set(fileKey(posKey()), JSON.stringify(out));
|
||
}
|
||
// ширины колонок дат, заданные вручную: {дата: px}; меньше COLW не бывает
|
||
const colWidths = () => { const v = store.json(fileKey("colw"), {}); return v && typeof v === "object" ? v : {}; };
|
||
// в режиме «по датам» карточка не выходит за свою колонку даты: x и ширина зажимаются по границам колонки
|
||
const CPAD = 6;
|
||
function clampToCol(k, x, w) {
|
||
const b = colBounds[k]; if (!b) return { x, w };
|
||
w = Math.max(MINW, Math.min(w, b.x1 - b.x0 - 2 * CPAD));
|
||
return { x: Math.max(b.x0 + CPAD, Math.min(x, b.x1 - CPAD - w)), w };
|
||
}
|
||
|
||
// ===== 3. Разбор файлов
|
||
function parseCsv(text) {
|
||
const rows = []; let row = [], cell = "", q = false;
|
||
if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1);
|
||
for (let i = 0; i < text.length; i++) {
|
||
const ch = text[i];
|
||
if (q) { if (ch === '"') { if (text[i + 1] === '"') { cell += '"'; i++; } else q = false; } else cell += ch; continue; }
|
||
if (ch === '"') q = true;
|
||
else if (ch === ",") { row.push(cell); cell = ""; }
|
||
else if (ch === "\n" || ch === "\r") { if (ch === "\r" && text[i + 1] === "\n") i++; row.push(cell); rows.push(row); row = []; cell = ""; }
|
||
else cell += ch;
|
||
}
|
||
if (cell !== "" || row.length) { row.push(cell); rows.push(row); }
|
||
return rows.filter(r => r.some(c => c !== ""));
|
||
}
|
||
const stubNode = key => ({ key, title: "нет в файле", kind: "", prio: "", due: "", status: "", epic: NOT_IN_FILE, sprint: "", stub: true, who: "" });
|
||
// связи без дублей; для «связанных» порядок концов не важен
|
||
function addLink(links, seen, src, dst, type) {
|
||
const id = type === RELATED ? [src, dst].sort().join("|") + type : src + ">" + dst + type;
|
||
if (seen.has(id)) return; seen.add(id); links.push({ src, dst, type });
|
||
}
|
||
// задачи, упомянутые в связях, но отсутствующие в файле, добавляются заглушками на дорожке «Нет в файле»
|
||
function addStubs(nodes, links) {
|
||
const keys = new Set(nodes.map(n => n.key)), missing = new Set();
|
||
links.forEach(l => [l.src, l.dst].forEach(k => { if (!keys.has(k)) missing.add(k); }));
|
||
missing.forEach(k => nodes.push(stubNode(k)));
|
||
return missing.size;
|
||
}
|
||
// CSV из фильтра Jira: нужны колонки Ключ проблемы, Тема, Статус, Приоритет, Тип задачи, Метки, Связи Фичи, Плановая дата окончания, Исполнитель, Спринт
|
||
// и «Внешняя/Внутренняя ссылка на проблему (ASUPR_…)». У задачи X «Внешняя ссылка (ASUPR_Блокер_Начала)» содержит Y, если X блокирует Y
|
||
// (outward-описание «Блокирует начало выполнения»); «Внутренняя ссылка» — если Y блокирует X. До 11.10.2026 связи в Jira стояли
|
||
// задом наперёд (ошибка направления в jira_sync.py), и страница читала «Внешняя» как блокеров; 11.10.2026 связи пересозданы, чтение исправлено.
|
||
function fromJiraCsv(text, fileName) {
|
||
const rows = parseCsv(text);
|
||
if (rows.length < 2) throw new Error("в файле нет строк с задачами");
|
||
const head = rows[0].map(h => h.trim());
|
||
const find = (...subs) => head.findIndex(h => subs.some(s => h.toLowerCase().includes(s.toLowerCase())));
|
||
const cols = {
|
||
key: find("Ключ проблемы", "Ключ задачи", "Issue key", "Ключ"), sum: find("Тема", "Резюме", "Summary"), status: find("Статус", "Status"),
|
||
prio: find("Приоритет", "Priority"), type: find("Тип задачи", "Тип запроса", "Issue Type"), epic: find("Связи Фичи"),
|
||
due: find("Плановая дата окончания", "Срок исполнения", "Due date"), who: find("Исполнитель", "Assignee"),
|
||
};
|
||
const labelCols = head.map((h, i) => (/^(Метки|Labels)$/i.test(h) ? i : -1)).filter(i => i >= 0);
|
||
const sprintCols = head.map((h, i) => (/^(Спринт|Sprint)$/i.test(h) ? i : -1)).filter(i => i >= 0);
|
||
if (cols.key < 0 || cols.sum < 0) throw new Error("не нашёл колонки «Ключ проблемы» и «Тема»; в файле: " + head.slice(0, 10).join(", ") + "…");
|
||
const linkCols = [];
|
||
head.forEach((h, i) => { for (const [t, type] of Object.entries(LINK_TYPES)) if (h.includes(t)) linkCols.push({ i, type, out: /outward|исход|внешн/.test(h.toLowerCase()) }); });
|
||
const problems = [];
|
||
if (!linkCols.length) problems.push("в файле нет колонок связей — в фильтре Jira добавьте колонку «Связанные задачи» или экспортируйте «все поля»");
|
||
if (!sprintCols.length) problems.push("в файле нет колонки «Спринт» — выбор спринта недоступен, все задачи считаются выбранными");
|
||
if (cols.epic < 0) problems.push("нет колонки «Связи Фичи» — дорожки по эпикам не построить");
|
||
const cell = (r, i) => (i >= 0 ? r[i] || "" : "").trim();
|
||
const isEpic = r => cols.type >= 0 && /^(Фича|Epic|Эпик)$/i.test(cell(r, cols.type));
|
||
const epicNames = { ...EPICS };
|
||
rows.slice(1).forEach(r => { const k = cell(r, cols.key); if (KEY_RE.test(k) && isEpic(r)) epicNames[k] = cell(r, cols.sum) || k; });
|
||
const nodes = [], links = [], seen = new Set(), sprints = {};
|
||
for (const r of rows.slice(1)) {
|
||
const key = cell(r, cols.key); if (!KEY_RE.test(key) || isEpic(r)) continue;
|
||
let kind = cell(r, cols.type);
|
||
for (const c of labelCols) { const v = cell(r, c); if (KIND_LABELS[v]) kind = KIND_LABELS[v]; }
|
||
const epicKey = cell(r, cols.epic);
|
||
const sprint = sprintCols.map(c => cell(r, c)).filter(Boolean).pop() || "";
|
||
if (sprint) sprints[sprint] = (sprints[sprint] || 0) + 1;
|
||
nodes.push({ key, title: cell(r, cols.sum), kind, prio: cell(r, cols.prio), due: sortableDate(cell(r, cols.due)), status: cell(r, cols.status),
|
||
epic: epicKey ? (epicNames[epicKey] || epicKey) : NO_EPIC, sprint, who: cell(r, cols.who), stub: false });
|
||
for (const lc of linkCols) {
|
||
const other = cell(r, lc.i); if (!KEY_RE.test(other)) continue;
|
||
addLink(links, seen, lc.out ? key : other, lc.out ? other : key, lc.type);
|
||
}
|
||
}
|
||
const missing = addStubs(nodes, links);
|
||
return { nodes, links, sprints, fileName, problems, hasSprint: sprintCols.length > 0, missing };
|
||
}
|
||
// JSON, выгруженный этой же страницей (см. exportJson): задачи, связи, раскладка, порядок и свёртка дорожек, фильтры
|
||
function fromJson(text, fileName) {
|
||
let d; try { d = JSON.parse(text); } catch (e) { throw new Error("это не JSON"); }
|
||
if (!d || !Array.isArray(d.nodes)) throw new Error("в JSON нет списка nodes — это не выгрузка этой страницы");
|
||
const keys = new Set(), nodes = [], links = [], seen = new Set(), sprints = {}, layout = {}, layoutByDate = {};
|
||
d.nodes.forEach(n => {
|
||
const key = String(n && n.key || "").trim(); if (!KEY_RE.test(key) || keys.has(key) || n.stub) return; keys.add(key);
|
||
const sprint = String(n.sprint || "").trim(); if (sprint) sprints[sprint] = (sprints[sprint] || 0) + 1;
|
||
nodes.push({ key, title: String(n.title || ""), kind: String(n.kind || ""), prio: String(n.priority || ""), due: sortableDate(String(n.due || "")),
|
||
status: String(n.status || ""), epic: String(n.epic || NO_EPIC), sprint, who: String(n.assignee || ""), stub: false });
|
||
const take = L => L && typeof L.x === "number" && typeof L.ry === "number" ? { x: L.x, ry: L.ry, w: typeof L.w === "number" ? L.w : CW, ...(typeof L.h === "number" ? { h: L.h } : {}) } : null;
|
||
const L = take(n.layout), LD = take(n.layoutByDate);
|
||
if (L) layout[key] = L;
|
||
if (LD) layoutByDate[key] = LD;
|
||
});
|
||
if (!nodes.length) throw new Error("в nodes нет ни одной задачи с ключом вида AURORA-1234");
|
||
const types = new Set(Object.values(LINK_TYPES));
|
||
(Array.isArray(d.links) ? d.links : []).forEach(l => {
|
||
const src = String(l && l.from || "").trim(), dst = String(l && l.to || "").trim(); if (!KEY_RE.test(src) || !KEY_RE.test(dst)) return;
|
||
addLink(links, seen, src, dst, types.has(l.type) ? l.type : "блокирует начало");
|
||
});
|
||
const missing = addStubs(nodes, links);
|
||
const f = d.filters && typeof d.filters === "object" ? d.filters : {};
|
||
return { model: { nodes, links, sprints, fileName: String(d.source || fileName), problems: [], hasSprint: nodes.some(n => n.sprint), missing },
|
||
layout, layoutByDate, dateColumns: d.dateColumns && typeof d.dateColumns === "object" ? d.dateColumns : null, laneOrder: strList(d.laneOrder), collapsedLanes: Array.isArray(d.collapsedLanes) ? strList(d.collapsedLanes) : null, filters: f };
|
||
}
|
||
|
||
// ===== 4. Выборка и анализ
|
||
// фильтры задач — пересечение спринта, исполнителя и вида работы; skip — какой из них не учитывать (для счётчиков в выпадашках).
|
||
// Заглушки «нет в файле» фильтры не проходят: они попадают на доску только как соседи по связям
|
||
const passes = (x, skip) => !x.stub
|
||
&& (skip === "sprint" || S.sprints.size === 0 || S.sprints.has(x.sprint))
|
||
&& (skip === "who" || S.who.size === 0 || S.who.has(whoKey(x.who)))
|
||
&& (skip === "kind" || !S.hideKind.has(KIND[x.kind] || "org"));
|
||
const edgeClass = l => (l.type === RELATED ? "rel" : "block");
|
||
// доска: ядро = задачи, прошедшие фильтры; контекст = их соседи по связям (если включён); стрелки = все связи между карточками на доске
|
||
// минус скрытые типы и, при «передачах», минус связи внутри одного человека; «скрыть без связей» убирает карточки без оставшихся стрелок.
|
||
// removed — сколько карточек/стрелок убрал каждый шаг, для строки статуса и объяснения пустой доски
|
||
function computeVisible() {
|
||
const all = MODEL.nodes, byKey = Object.fromEntries(all.map(x => [x.key, x]));
|
||
const isHand = l => l.type !== RELATED && whoKey(byKey[l.src].who) !== whoKey(byKey[l.dst].who);
|
||
const core = new Set(all.filter(x => passes(x)).map(x => x.key));
|
||
let keep = new Set(core);
|
||
if (S.neighbors) MODEL.links.forEach(l => { if (core.has(l.src) || core.has(l.dst)) { keep.add(l.src); keep.add(l.dst); } });
|
||
const removed = { core: core.size, ctx: keep.size - core.size, edge: 0, loose: 0 };
|
||
let links = MODEL.links.filter(l => keep.has(l.src) && keep.has(l.dst));
|
||
const allLinks = links.length;
|
||
if (S.hideEdge.size) links = links.filter(l => !S.hideEdge.has(edgeClass(l)));
|
||
if (S.onlyHand) links = links.filter(isHand);
|
||
removed.edge = allLinks - links.length;
|
||
if (S.onlyLinked) { const ends = new Set(links.flatMap(l => [l.src, l.dst])); const before = keep.size; keep = new Set([...keep].filter(k => ends.has(k))); removed.loose = before - keep.size; }
|
||
links = links.map(l => ({ ...l, hand: isHand(l) }));
|
||
// ядро сплошной рамкой, соседи пунктиром
|
||
const nodes = all.filter(x => keep.has(x.key)).map(x => ({ ...x, dim: !core.has(x.key), focus: false }));
|
||
const crit = S.crit ? criticalPath(nodes, links) : { nodes: new Set(), edges: new Set() };
|
||
nodes.forEach(x => { x.crit = crit.nodes.has(x.key); });
|
||
links.forEach(l => { l.crit = crit.edges.has(l.src + ">" + l.dst); });
|
||
// дорожки по эпикам: по алфавиту, служебные внизу, затем сохранённый порядок пользователя
|
||
const byEpic = {}; nodes.forEach(x => (byEpic[x.epic] = byEpic[x.epic] || []).push(x.key));
|
||
const tail = name => name === NO_EPIC || name === NOT_IN_FILE;
|
||
const lanes = Object.entries(byEpic).sort((a, b) => tail(a[0]) - tail(b[0]) || a[0].localeCompare(b[0], "ru")).map(([name, keys]) => ({ name, keys }));
|
||
const order = laneOrder();
|
||
if (order.length) lanes.sort((a, b) => { const ia = order.indexOf(a.name), ib = order.indexOf(b.name); return (ia < 0 ? 1e9 : ia) - (ib < 0 ? 1e9 : ib); });
|
||
return { nodes, links, lanes, removed, problems: findProblems(nodes, links) };
|
||
}
|
||
// критический путь: самая длинная цепочка блокировок; при равной длине — с наименьшим запасом по плановым датам окончания
|
||
function criticalPath(nodes, links) {
|
||
const byKey = Object.fromEntries(nodes.map(n => [n.key, n])), out = {}; nodes.forEach(n => out[n.key] = []);
|
||
links.filter(l => l.type !== RELATED).forEach(l => out[l.src].push(l.dst));
|
||
const memo = {};
|
||
const best = (k, seen = new Set()) => { // {len, slack, path} от k; запас = сумма (срок зависимой − срок блокера) по рёбрам
|
||
if (memo[k]) return memo[k]; if (seen.has(k)) return { len: 0, slack: 0, path: [k] }; seen.add(k);
|
||
let r = { len: 0, slack: 0, path: [k] };
|
||
out[k].forEach(d => {
|
||
const b = best(d, seen), sl = dayDiff(byKey[k].due, byKey[d].due);
|
||
const cand = { len: b.len + 1, slack: b.slack + (sl === null ? 0 : Math.max(0, sl)), path: [k, ...b.path] };
|
||
if (cand.len > r.len || (cand.len === r.len && cand.slack < r.slack)) r = cand;
|
||
});
|
||
memo[k] = r; return r;
|
||
};
|
||
let top = null; nodes.forEach(n => { const b = best(n.key); if (!top || b.len > top.len || (b.len === top.len && b.slack < top.slack)) top = b; });
|
||
const res = { nodes: new Set(), edges: new Set() };
|
||
if (top && top.len > 0) { top.path.forEach(k => res.nodes.add(k)); for (let i = 0; i + 1 < top.path.length; i++) res.edges.add(top.path[i] + ">" + top.path[i + 1]); }
|
||
return res;
|
||
}
|
||
// точки риска по блокирующим связям видимой части доски
|
||
function findProblems(nodes, links) {
|
||
const byKey = Object.fromEntries(nodes.map(n => [n.key, n])), today = new Date().toISOString().slice(0, 10);
|
||
const dates = [], unassignedList = [], late = [], hand = [];
|
||
links.filter(l => l.type !== RELATED).forEach(l => {
|
||
const a = byKey[l.src], b = byKey[l.dst]; if (a.stub || b.stub) return;
|
||
const sl = dayDiff(a.due, b.due);
|
||
if (sl !== null && sl <= 0) dates.push({ a, b, text: sl === 0 ? `сроки совпадают (${fmtDate(a.due)}): запаса нет` : `блокер должен закончиться ${fmtDate(a.due)}, а зависимая — уже ${fmtDate(b.due)}` });
|
||
if (!whoKey(a.who)) unassignedList.push({ a, b, text: whoKey(b.who) ? `${shortName(b.who)} ждёт задачу без исполнителя` : "блокер без исполнителя" });
|
||
const left = dayDiff(today, b.due);
|
||
if (notStarted(a.status) && left !== null && left <= 7) late.push({ a, b, text: `блокер в статусе «${a.status}», а до срока зависимой ${left < 0 ? "срок прошёл" : left + " дн."}` });
|
||
if (l.hand && whoKey(a.who) && whoKey(b.who)) hand.push({ a, b, text: `${shortName(b.who)} ждёт ${shortName(a.who)}` });
|
||
});
|
||
return { dates, unassigned: unassignedList, late, hand };
|
||
}
|
||
function emptyReason() {
|
||
const r = VIS.removed;
|
||
if (!MODEL.nodes.some(n => !n.stub)) return "В файле нет задач.";
|
||
if (!r.core) {
|
||
const active = [S.sprints.size ? "спринт" : "", S.who.size ? "исполнитель" : "", S.hideKind.size ? "виды работ в легенде" : ""].filter(Boolean);
|
||
return `Фильтры не оставили ни одной задачи (${active.join(", ")}). Ослабьте один из них: «Снять выбор» в выпадашке или щелчок по зачёркнутому виду работы.`;
|
||
}
|
||
if (r.loose) return `Все ${r.loose} задач скрыты кнопкой «скрыть без связей»: ${S.onlyHand ? "передач между людьми на доске нет" : "видимых стрелок у них нет"}. Отключите её${S.onlyHand ? " или «передачи»" : S.hideEdge.size ? " или верните тип связи в легенде" : ""}.`;
|
||
return "Нет задач для показа.";
|
||
}
|
||
|
||
// ===== 5. Раскладка и отрисовка доски
|
||
// слой задачи = длина самой длинной цепочки блокеров до неё; внутри дорожки карточки слоя стоят колонкой, не больше MAXROWS в подколонке.
|
||
// В режиме «по датам» слой = порядковый номер плановой даты окончания среди видимых задач (без срока — последняя колонка), раскладка берётся из своего хранилища
|
||
function render() {
|
||
VIS = computeVisible();
|
||
const data = VIS;
|
||
board.querySelectorAll(".lane,.card,.empty,.dcol").forEach(e => e.remove());
|
||
rel = {}; pos = {}; laneOf = {}; lanesGeom = []; cardEls = {}; dateEls = []; colBounds = {};
|
||
board.classList.toggle("bydate", S.byDate); $("stage").classList.toggle("bydate", S.byDate && data.nodes.length > 0);
|
||
$("datesBar").hidden = !S.byDate || !data.nodes.length;
|
||
renderProblems(); renderTable();
|
||
if (!data.nodes.length) {
|
||
board.style.width = "100%"; board.style.height = "auto";
|
||
const d = document.createElement("div"); d.className = "empty"; d.textContent = emptyReason(); board.appendChild(d);
|
||
edgesSvg.innerHTML = ARROWS; hscrollEl.hidden = true; return;
|
||
}
|
||
const byKey = Object.fromEntries(data.nodes.map(n => [n.key, n]));
|
||
const preds = {}; data.nodes.forEach(n => preds[n.key] = []); data.links.forEach(l => preds[l.dst].push(l.src));
|
||
const layer = {};
|
||
const L = (k, seen = new Set()) => { if (layer[k] !== undefined) return layer[k]; if (seen.has(k)) return 0; seen.add(k); layer[k] = preds[k].length ? Math.max(...preds[k].map(p => L(p, seen))) + 1 : 0; return layer[k]; };
|
||
const dateCols = S.byDate ? [...new Set(data.nodes.map(n => n.due || ""))].sort((a, b) => (a || "~").localeCompare(b || "~")) : null;
|
||
if (dateCols) data.nodes.forEach(n => { layer[n.key] = dateCols.indexOf(n.due || ""); }); else data.nodes.forEach(n => L(n.key));
|
||
const maxLayer = Math.max(0, ...Object.values(layer)), layerCols = Array(maxLayer + 1).fill(1);
|
||
data.lanes.forEach(lane => { const cnt = {}; lane.keys.forEach(k => { cnt[layer[k]] = (cnt[layer[k]] || 0) + 1; }); Object.entries(cnt).forEach(([c, n]) => { layerCols[c] = Math.max(layerCols[c], Math.ceil(n / MAXROWS)); }); });
|
||
const cw = dateCols ? colWidths() : {};
|
||
const colW = c => dateCols && typeof cw[dateCols[c]] === "number" ? Math.max(layerCols[c] * COLW, cw[dateCols[c]]) : layerCols[c] * COLW; // ручная ширина не уже автоматической
|
||
const layerX = []; let x = LEFT; for (let c = 0; c <= maxLayer; c++) { layerX[c] = x; x += colW(c); }
|
||
baseW = x;
|
||
const stacks = []; // подколонки: карточки сверху вниз, высота — по содержимому (считается после отрисовки)
|
||
data.lanes.forEach((lane, li) => {
|
||
const cols = {};
|
||
lane.keys.forEach(k => { (cols[layer[k]] = cols[layer[k]] || []).push(k); laneOf[k] = li; });
|
||
Object.entries(cols).forEach(([c, ks]) => {
|
||
ks.sort((a, b) => ((byKey[a].due || "9") > (byKey[b].due || "9") ? 1 : -1));
|
||
const per = Math.ceil(ks.length / Math.ceil(ks.length / MAXROWS)), sub = [];
|
||
ks.forEach((k, i) => { const col = Math.floor(i / per); rel[k] = { x: layerX[c] + col * COLW, ry: TOP, w: CW, h: MINH, auto: true }; (sub[col] = sub[col] || []).push(k); });
|
||
stacks.push(...sub);
|
||
});
|
||
lanesGeom.push({ name: lane.name, y: 0, h: 0, minH: TOP + MINH + 24, folded: false, el: null });
|
||
});
|
||
lanesGeom.forEach((g, li) => renderLane(g, li, data.lanes[li].keys.length));
|
||
if (dateCols) { renderDateCols(dateCols, layerX, colW, data.nodes); data.nodes.forEach(n => { const c = layer[n.key], x0 = layerX[c] - LEFT / 2; colBounds[n.key] = { x0, x1: x0 + colW(c) }; }); }
|
||
const multiSprint = new Set(data.nodes.filter(n => !n.stub).map(n => n.sprint)).size > 1; // метка спринта нужна, только если спринтов на доске больше одного
|
||
data.nodes.forEach(n => renderCard(n, multiSprint));
|
||
// высота по содержимому: снимаем ограничение, измеряем, округляем к сетке; other — высота всего, кроме заголовка (для обрезки заголовка при ручной высоте)
|
||
data.nodes.forEach(n => { const c = cardEls[n.key], t = c.querySelector(".title"); c.style.width = rel[n.key].w + "px"; c.style.height = "auto"; t.style.webkitLineClamp = "unset"; rel[n.key].h = measuredHeight(c); rel[n.key].other = c.offsetHeight - t.offsetHeight; });
|
||
stacks.forEach(list => { let y = TOP; list.forEach(k => { rel[k].ry = y; y += rel[k].h + 16; }); });
|
||
const saved = store.json(fileKey(posKey()), null);
|
||
if (saved && typeof saved === "object") Object.entries(saved).forEach(([k, v]) => {
|
||
if (!rel[k] || typeof v.x !== "number" || typeof v.ry !== "number") return;
|
||
const base = colBounds[k] ? colBounds[k].x0 : 0;
|
||
const manualH = typeof v.h === "number", c = clampToCol(k, Math.max(0, base + v.x), typeof v.w === "number" ? v.w : rel[k].w); // дата могла смениться — карточка остаётся в своей колонке
|
||
rel[k] = { x: c.x, ry: Math.max(TOP, v.ry), w: c.w, h: manualH ? v.h : rel[k].h, auto: !manualH, other: rel[k].other };
|
||
});
|
||
relayout(); drawEdges();
|
||
}
|
||
const measuredHeight = c => Math.max(MINH, Math.ceil(c.offsetHeight / GRID) * GRID);
|
||
// колонки дат: линия и подкраска на всю высоту доски (высота ставится в relayout), подписи — в липкой полосе над доской;
|
||
// прошедшие даты подкрашены, сегодняшняя выделена
|
||
function renderDateCols(dates, layerX, colW, nodes) {
|
||
const today = new Date().toISOString().slice(0, 10), track = $("datesTrack"); track.innerHTML = "";
|
||
dates.forEach((d, c) => {
|
||
const el = document.createElement("div"), count = nodes.filter(n => (n.due || "") === d).length, x0 = layerX[c] - LEFT / 2, w = colW(c);
|
||
const cls = d ? (d < today ? " past" : d === today ? " today" : "") : " none";
|
||
el.className = "dcol" + cls; el.style.left = x0 + "px"; el.style.width = w + "px";
|
||
board.appendChild(el); dateEls.push(el);
|
||
const lb = document.createElement("span"); lb.className = "dlabel" + cls; lb.style.left = (x0 + 8) + "px";
|
||
lb.title = d ? (d < today ? "срок прошёл" : d === today ? "сегодня" : "плановая дата окончания") : "плановая дата не задана";
|
||
lb.innerHTML = `${d ? fmtDate(d) : "без срока"}<span class="n">${count}</span>`; track.appendChild(lb);
|
||
// ручка правой границы: тянуть — менять ширину колонки, двойной щелчок — вернуть автоматическую
|
||
const sp = document.createElement("div"); sp.className = "dsplit"; sp.style.left = (x0 + w) + "px"; sp.title = "Тянуть — ширина колонки; двойной щелчок — автоматическая";
|
||
sp.addEventListener("pointerdown", ev => startColResize(ev, d, w, sp));
|
||
sp.addEventListener("dblclick", () => { const cw = colWidths(); delete cw[d]; store.set(fileKey("colw"), JSON.stringify(cw)); render(); });
|
||
track.appendChild(sp);
|
||
});
|
||
}
|
||
// перетаскивание границы колонки: слушатели на window, потому что доска перерисовывается на каждом кадре и ручка пересоздаётся
|
||
function startColResize(ev, date, w0, handle) {
|
||
ev.preventDefault(); handle.classList.add("on"); $("datesIn").classList.add("resizing");
|
||
const x0 = ev.clientX; let raf = 0, last = w0;
|
||
const apply = w => { const cw = colWidths(); if (w <= COLW) delete cw[date]; else cw[date] = Math.round(w / GRID) * GRID; store.set(fileKey("colw"), JSON.stringify(cw)); };
|
||
const move = e => { last = Math.max(COLW, w0 + e.clientX - x0); if (!raf) raf = requestAnimationFrame(() => { raf = 0; apply(last); render(); }); };
|
||
const stop = () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", stop); window.removeEventListener("pointercancel", stop); $("datesIn").classList.remove("resizing"); apply(last); render(); };
|
||
window.addEventListener("pointermove", move); window.addEventListener("pointerup", stop); window.addEventListener("pointercancel", stop);
|
||
}
|
||
// полоса дат: ширина как у доски, сдвиг как у горизонтальной прокрутки доски; липкая полоса прокрутки внизу окна повторяет прокрутку доски и управляет ею
|
||
const wrapEl = $("wrap"), hscrollEl = $("hscroll");
|
||
function syncDates() {
|
||
$("datesIn").style.width = wrapEl.offsetWidth + "px"; $("datesTrack").style.transform = `translateX(${-wrapEl.scrollLeft}px)`;
|
||
hscrollEl.hidden = wrapEl.scrollWidth <= wrapEl.clientWidth + 1;
|
||
hscrollEl.style.width = wrapEl.offsetWidth + "px"; $("hscrollIn").style.width = wrapEl.scrollWidth + "px";
|
||
if (Math.abs(hscrollEl.scrollLeft - wrapEl.scrollLeft) > 1) hscrollEl.scrollLeft = wrapEl.scrollLeft;
|
||
}
|
||
wrapEl.addEventListener("scroll", syncDates);
|
||
hscrollEl.addEventListener("scroll", () => { if (Math.abs(hscrollEl.scrollLeft - wrapEl.scrollLeft) > 1) wrapEl.scrollLeft = hscrollEl.scrollLeft; });
|
||
if (window.ResizeObserver) new ResizeObserver(syncDates).observe(wrapEl);
|
||
function renderLane(g, li, count) {
|
||
const d = document.createElement("div"); d.className = "lane";
|
||
d.innerHTML = `<div class="lane-head"><button type="button" class="fold" title="Свернуть или развернуть дорожку"></button><span class="lane-name" title="Перетащить, чтобы поменять дорожки местами">${esc(g.name)}</span><span class="lane-count">${plural(count, "задача", "задачи", "задач")}</span><span class="lane-tools"><button type="button" title="Выше" ${li === 0 ? "disabled" : ""}>▲</button><button type="button" title="Ниже" ${li === lanesGeom.length - 1 ? "disabled" : ""}>▼</button></span></div>`;
|
||
board.appendChild(d); g.el = d;
|
||
d.querySelector(".fold").addEventListener("click", () => toggleLane(g.name));
|
||
const [up, down] = d.querySelectorAll(".lane-tools button");
|
||
up.addEventListener("click", () => moveLane(g.name, li - 1));
|
||
down.addEventListener("click", () => moveLane(g.name, li + 1));
|
||
// перетаскивание дорожки за название: подсветка целевой границы, перестановка при отпускании
|
||
const nameEl = d.querySelector(".lane-name");
|
||
nameEl.addEventListener("pointerdown", ev => {
|
||
ev.preventDefault(); nameEl.setPointerCapture(ev.pointerId); d.classList.add("dragging");
|
||
let target = li;
|
||
const where = e => { const y = e.clientY - board.getBoundingClientRect().top; const i = lanesGeom.findIndex(g => y < g.y + g.h / 2); return i < 0 ? lanesGeom.length - 1 : i; };
|
||
const move = e => { target = where(e); lanesGeom.forEach((x, i) => { x.el.classList.toggle("target", i === target && i < li); x.el.classList.toggle("target-after", i === target && i > li); }); };
|
||
const stop = () => { nameEl.removeEventListener("pointermove", move); nameEl.removeEventListener("pointerup", stop); nameEl.removeEventListener("pointercancel", stop); d.classList.remove("dragging"); lanesGeom.forEach(x => x.el.classList.remove("target", "target-after")); if (target !== li) moveLane(g.name, target); };
|
||
nameEl.addEventListener("pointermove", move); nameEl.addEventListener("pointerup", stop); nameEl.addEventListener("pointercancel", stop);
|
||
});
|
||
}
|
||
function renderCard(n, multiSprint) {
|
||
const c = document.createElement("div");
|
||
c.className = "card" + (n.dim || n.stub ? " out" : "") + (n.crit ? " crit" : "") + (n.focus ? " focus" : ""); c.title = n.title;
|
||
const due = n.due ? `<span class="due">до ${fmtDate(n.due)}</span>` : "";
|
||
const state = n.status || n.due ? `<div class="state">${n.status ? `<span class="status" title="${esc(n.status)}">${esc(n.status)}</span>` : ""}${due}</div>` : "";
|
||
const tag = n.stub || !(multiSprint || n.dim) ? "" : n.sprint
|
||
? `<span class="sprint-tag${S.sprints.has(n.sprint) ? " sel" : ""}" title="${esc(n.sprint)}">${esc(n.sprint.replace(/^AWPCA_/, ""))}</span>`
|
||
: `<span class="sprint-tag none">без спринта</span>`;
|
||
const who = n.stub ? "" : `<div class="who${unassigned(n.who) ? " none" : ""}" title="${esc(n.who)}">${esc(shortName(n.who))}</div>`;
|
||
c.innerHTML = `<div class="head"><a class="key" href="${JIRA}/browse/${n.key}" target="_blank" rel="noopener">${n.key}</a>${tag}</div>
|
||
<div class="title">${esc(n.title)}</div>
|
||
<div class="meta"><span class="kind"><i style="background:var(--k-${KIND[n.kind] || "org"})"></i>${esc(n.kind)}</span><span class="prio" style="color:var(--p-${PRIO[n.prio] || "mid"})">${esc(n.prio)}</span></div>
|
||
${state}${who}
|
||
<div class="rsz" title="Потянуть — изменить размер; двойной щелчок — размер по содержимому"></div><div class="grip t" title="Тянуть за рамку"></div><div class="grip b"></div><div class="grip l"></div><div class="grip r"></div>`;
|
||
cardEls[n.key] = c; board.appendChild(c);
|
||
c.addEventListener("pointerdown", ev => startDrag(ev, n.key, c));
|
||
c.querySelector(".rsz").addEventListener("dblclick", () => { const cc = clampToCol(n.key, rel[n.key].x, CW); rel[n.key] = { ...rel[n.key], x: cc.x, w: cc.w, auto: true }; rel[n.key].h = autoHeight(n.key); relayout(); drawEdges(); saveLayout(); });
|
||
}
|
||
// перетаскивание за рамку или изменение размера за уголок: направляющие по соседям, привязка к сетке, карточка не выше своей дорожки
|
||
function startDrag(ev, key, c) {
|
||
if (ev.target.closest("a")) return;
|
||
const resizing = !!ev.target.closest(".rsz");
|
||
if (!resizing && !ev.target.closest(".grip")) return; // остальное — текст, его можно выделять
|
||
ev.preventDefault();
|
||
const q0 = { ...pos[key] }, start = { x: ev.clientX, y: ev.clientY };
|
||
c.classList.add("drag"); c.setPointerCapture(ev.pointerId);
|
||
const move = e => {
|
||
const dx = e.clientX - start.x, dy = e.clientY - start.y;
|
||
if (resizing) {
|
||
let { w, h } = alignEdges(key, { x: q0.x, y: q0.y, w: Math.max(MINW, q0.w + dx), h: Math.max(MINH, q0.h + dy) }, true);
|
||
if (S.snap) { w = Math.round(w / GRID) * GRID; h = Math.round(h / GRID) * GRID; }
|
||
const cc = clampToCol(key, rel[key].x, Math.max(MINW, w));
|
||
rel[key] = { ...rel[key], w: cc.w, h: Math.max(MINH, h), auto: false };
|
||
} else {
|
||
const g = alignEdges(key, { x: Math.max(0, q0.x + dx), y: Math.max(0, q0.y + dy), w: q0.w, h: q0.h }, false);
|
||
let { x, y } = g;
|
||
if (S.snap && !g.snappedX) x = Math.round(x / GRID) * GRID;
|
||
if (S.snap && !g.snappedY) y = Math.round(y / GRID) * GRID;
|
||
rel[key] = { ...rel[key], x: clampToCol(key, Math.max(0, x), rel[key].w).x, ry: Math.max(TOP, y - lanesGeom[laneOf[key]].y) };
|
||
}
|
||
relayout(); drawEdges();
|
||
};
|
||
const stop = () => { c.classList.remove("drag"); c.removeEventListener("pointermove", move); c.removeEventListener("pointerup", stop); c.removeEventListener("pointercancel", stop); guidesSvg.innerHTML = ""; relayout(); saveLayout(); };
|
||
c.addEventListener("pointermove", move); c.addEventListener("pointerup", stop); c.addEventListener("pointercancel", stop);
|
||
}
|
||
function autoHeight(k) {
|
||
const c = cardEls[k]; if (!c) return rel[k].h;
|
||
c.style.height = "auto"; c.querySelector(".title").style.webkitLineClamp = "unset";
|
||
return measuredHeight(c);
|
||
}
|
||
// координаты дорожек и карточек по rel: дорожка растёт под карточки, свёрнутая — только шапка, её карточки спрятаны и в pos не попадают
|
||
function relayout() {
|
||
const folded = foldedSet(); let yy = 0, w = baseW; pos = {};
|
||
lanesGeom.forEach((g, li) => {
|
||
g.folded = folded.has(g.name);
|
||
let h = g.folded ? FOLD_H : g.minH;
|
||
if (!g.folded) Object.entries(rel).forEach(([k, r]) => { if (laneOf[k] === li) { h = Math.max(h, r.ry + r.h + 12); w = Math.max(w, r.x + r.w + LEFT); } });
|
||
g.y = yy; g.h = h; yy += h;
|
||
g.el.style.top = g.y + "px"; g.el.style.height = g.h + "px"; g.el.classList.toggle("folded", g.folded);
|
||
});
|
||
Object.entries(rel).forEach(([k, r]) => {
|
||
const el = cardEls[k], g = lanesGeom[laneOf[k]];
|
||
if (g.folded) { if (el) el.hidden = true; return; }
|
||
pos[k] = { x: r.x, y: g.y + r.ry, w: r.w, h: r.h };
|
||
if (!el) return;
|
||
el.hidden = false; el.style.left = pos[k].x + "px"; el.style.top = pos[k].y + "px"; el.style.width = r.w + "px"; el.style.height = r.h + "px";
|
||
el.querySelector(".title").style.webkitLineClamp = r.auto ? "unset" : String(Math.max(1, Math.floor((r.h - (r.other || 112)) / TITLE_LH)));
|
||
});
|
||
board.style.width = w + "px"; board.style.height = yy + "px";
|
||
dateEls.forEach(el => { el.style.height = yy + "px"; });
|
||
if (S.byDate) $("datesTrack").style.width = w + "px";
|
||
syncDates();
|
||
}
|
||
function toggleLane(name) { const s = foldedSet(); if (s.has(name)) s.delete(name); else s.add(name); store.set(fileKey("fold"), JSON.stringify([...s])); relayout(); drawEdges(); }
|
||
function moveLane(name, to) {
|
||
const names = VIS.lanes.map(l => l.name), from = names.indexOf(name);
|
||
if (from < 0 || to < 0 || to >= names.length || from === to) return;
|
||
names.splice(to, 0, names.splice(from, 1)[0]);
|
||
store.set(fileKey("lanes"), JSON.stringify(names.concat(laneOrder().filter(n => !names.includes(n)))));
|
||
render();
|
||
}
|
||
// стрелка от блокера к зависимой — ломаная из горизонтальных и вертикальных отрезков со скруглёнными углами: выходит из карточки и входит
|
||
// в карточку перпендикулярно грани. Карточки разнесены по горизонтали — через середину промежутка; иначе по вертикали через середину промежутка;
|
||
// карточки перекрываются и по вертикали — обход справа от обеих
|
||
const EDGE_R = 10;
|
||
function orthoPath(pts) {
|
||
let d = `M${pts[0].x} ${pts[0].y}`;
|
||
for (let i = 1; i < pts.length - 1; i++) {
|
||
const A = pts[i - 1], P = pts[i], B = pts[i + 1];
|
||
const l1 = Math.hypot(P.x - A.x, P.y - A.y), l2 = Math.hypot(B.x - P.x, B.y - P.y), r = Math.min(EDGE_R, l1 / 2, l2 / 2);
|
||
if (r < 1) { d += ` L${P.x} ${P.y}`; continue; }
|
||
const u1 = { x: (P.x - A.x) / l1, y: (P.y - A.y) / l1 }, u2 = { x: (B.x - P.x) / l2, y: (B.y - P.y) / l2 };
|
||
d += ` L${P.x - u1.x * r} ${P.y - u1.y * r} Q${P.x} ${P.y} ${P.x + u2.x * r} ${P.y + u2.y * r}`;
|
||
}
|
||
const E = pts[pts.length - 1]; return d + ` L${E.x} ${E.y}`;
|
||
}
|
||
function edgePath(a, b) {
|
||
if (b.x >= a.x + a.w + 20) { const x1 = a.x + a.w, y1 = a.y + a.h / 2, x2 = b.x, y2 = b.y + b.h / 2, xm = Math.round((x1 + x2) / 2); return orthoPath(y1 === y2 ? [{ x: x1, y: y1 }, { x: x2, y: y2 }] : [{ x: x1, y: y1 }, { x: xm, y: y1 }, { x: xm, y: y2 }, { x: x2, y: y2 }]); }
|
||
if (a.x >= b.x + b.w + 20) { const x1 = a.x, y1 = a.y + a.h / 2, x2 = b.x + b.w, y2 = b.y + b.h / 2, xm = Math.round((x1 + x2) / 2); return orthoPath(y1 === y2 ? [{ x: x1, y: y1 }, { x: x2, y: y2 }] : [{ x: x1, y: y1 }, { x: xm, y: y1 }, { x: xm, y: y2 }, { x: x2, y: y2 }]); }
|
||
const overlapY = b.y < a.y + a.h + 20 && b.y + b.h + 20 > a.y;
|
||
if (overlapY) { const y1 = a.y + a.h / 2, y2 = b.y + b.h / 2, xr = Math.max(a.x + a.w, b.x + b.w) + 28; return orthoPath([{ x: a.x + a.w, y: y1 }, { x: xr, y: y1 }, { x: xr, y: y2 }, { x: b.x + b.w, y: y2 }]); }
|
||
const down = b.y >= a.y, x1 = a.x + a.w / 2, y1 = down ? a.y + a.h : a.y, x2 = b.x + b.w / 2, y2 = down ? b.y : b.y + b.h, ym = Math.round((y1 + y2) / 2);
|
||
return orthoPath(x1 === x2 ? [{ x: x1, y: y1 }, { x: x2, y: y2 }] : [{ x: x1, y: y1 }, { x: x1, y: ym }, { x: x2, y: ym }, { x: x2, y: y2 }]);
|
||
}
|
||
function drawEdges() {
|
||
edgesSvg.innerHTML = ARROWS;
|
||
VIS.links.forEach(l => {
|
||
const a = pos[l.src], b = pos[l.dst]; if (!a || !b) return;
|
||
const related = l.type === RELATED, p = document.createElementNS(NS, "path");
|
||
p.setAttribute("d", edgePath(a, b)); p.setAttribute("fill", "none");
|
||
p.setAttribute("stroke", related ? "var(--edge)" : l.hand ? "var(--guide)" : "var(--edge-strong)");
|
||
p.setAttribute("stroke-width", l.crit ? "4" : related ? "1.4" : "1.8");
|
||
if (related) p.setAttribute("stroke-dasharray", "5 4"); else p.setAttribute("marker-end", l.hand ? "url(#arrHand)" : "url(#arr)");
|
||
edgesSvg.appendChild(p);
|
||
});
|
||
}
|
||
// примагничивание к краям и центрам соседей в пределах SNAP px; рисует направляющие
|
||
function alignEdges(key, r, resizing) {
|
||
const xs = [], ys = [];
|
||
Object.entries(pos).forEach(([k, q]) => { if (k !== key) { xs.push(q.x, q.x + q.w / 2, q.x + q.w); ys.push(q.y, q.y + q.h / 2, q.y + q.h); } });
|
||
const mine = resizing ? { x: [r.x + r.w], y: [r.y + r.h] } : { x: [r.x, r.x + r.w / 2, r.x + r.w], y: [r.y, r.y + r.h / 2, r.y + r.h] };
|
||
const nearest = (vals, targets) => { let best = null; vals.forEach(v => targets.forEach(t => { const d = t - v; if (Math.abs(d) <= SNAP && (!best || Math.abs(d) < Math.abs(best.d))) best = { d, t }; })); return best; };
|
||
const bx = nearest(mine.x, xs), by = nearest(mine.y, ys);
|
||
const out = { x: r.x, y: r.y, w: r.w, h: r.h, snappedX: !!bx, snappedY: !!by };
|
||
if (bx) { if (resizing) out.w += bx.d; else out.x += bx.d; }
|
||
if (by) { if (resizing) out.h += by.d; else out.y += by.d; }
|
||
guidesSvg.innerHTML = "";
|
||
const line = (x1, y1, x2, y2) => { const l = document.createElementNS(NS, "line"); l.setAttribute("x1", x1); l.setAttribute("y1", y1); l.setAttribute("x2", x2); l.setAttribute("y2", y2); guidesSvg.appendChild(l); };
|
||
if (bx) line(bx.t, 0, bx.t, board.offsetHeight);
|
||
if (by) line(0, by.t, board.offsetWidth, by.t);
|
||
return out;
|
||
}
|
||
|
||
// ===== 6. Панель управления: статистика, панель проблем, выпадающие списки, легенда, меню
|
||
const setToggle = (id, on) => $(id).setAttribute("aria-pressed", String(on));
|
||
const refresh = () => { updateFacets(); render(); renderStatus(); };
|
||
function renderStatus() {
|
||
const m = MODEL, v = VIS, r = v.removed;
|
||
const coreShown = v.nodes.filter(n => !n.dim).length, ctxShown = v.nodes.length - coreShown;
|
||
const parts = [`<b>${coreShown}</b> задач из ${m.nodes.filter(n => !n.stub).length}`];
|
||
if (ctxShown) parts.push(`<b>+${ctxShown}</b> соседи по связям`);
|
||
parts.push(`<b>${v.links.length}</b> связей из ${m.links.length}`);
|
||
if (r.loose) parts.push(`скрыто без связей: ${r.loose}`);
|
||
$("stats").innerHTML = parts.join(" · ");
|
||
const notes = m.problems.map(esc);
|
||
if (m.missing) notes.push(`упомянуты в связях, но в файле отсутствуют: ${m.missing}`);
|
||
$("notice").hidden = !notes.length; $("notice").innerHTML = notes.map(n => `<span>${n}</span>`).join("");
|
||
}
|
||
function renderProblems() {
|
||
const pr = VIS.problems || { dates: [], unassigned: [], late: [], hand: [] };
|
||
const total = pr.dates.length + pr.unassigned.length + pr.late.length;
|
||
$("probCount").textContent = String(total); $("probCount").classList.toggle("zero", !total);
|
||
const link = k => `<a class="k" href="${JIRA}/browse/${k}" target="_blank" rel="noopener">${k}</a>`;
|
||
const sec = (title, list) => list.length ? `<h4>${title} (${list.length})</h4><ul>${list.map(x => `<li>${link(x.a.key)} → ${link(x.b.key)} <span class="why">— ${esc(x.text)}</span></li>`).join("")}</ul>` : "";
|
||
$("probBody").innerHTML = (total ? "" : `<div class="ok">По датам, исполнителям и статусам блокеров на видимой части доски проблем нет.</div>`)
|
||
+ sec("Срок блокера не раньше срока зависимой задачи", pr.dates) + sec("Блокер без исполнителя", pr.unassigned)
|
||
+ sec("Блокер не начат, а срок зависимой задачи близко", pr.late) + sec("Передачи между исполнителями (точки ожидания)", pr.hand);
|
||
}
|
||
function renderTable() {
|
||
$("tblBody").innerHTML = VIS.links.map(l => `<tr><td class="k">${l.src}</td><td>${esc(l.type)}</td><td class="k">${l.dst}</td></tr>`).join("");
|
||
}
|
||
const showProblems = on => { $("problems").hidden = !on; setToggle("probBtn", on); store.set(pref("prob"), on ? "1" : "0"); };
|
||
// общее поведение выпадающих списков: кнопка открывает, щелчок вне или Escape закрывает
|
||
function dropdown(dd, btn, menu, onOpen) {
|
||
const open = () => { menu.hidden = false; btn.setAttribute("aria-expanded", "true"); if (onOpen) onOpen(); };
|
||
const close = () => { menu.hidden = true; btn.setAttribute("aria-expanded", "false"); };
|
||
btn.addEventListener("click", () => (menu.hidden ? open() : close()));
|
||
document.addEventListener("pointerdown", e => { if (!dd.contains(e.target)) close(); });
|
||
dd.addEventListener("keydown", e => { if (e.key === "Escape") { close(); btn.focus(); } });
|
||
return { open, close };
|
||
}
|
||
const ddShell = (box, inner) => {
|
||
box.innerHTML = ""; const dd = document.createElement("div"); dd.className = "dd";
|
||
dd.innerHTML = `<button type="button" class="btn dd-btn" aria-haspopup="listbox" aria-expanded="false"><span class="lbl"></span><span class="car">▼</span></button><div class="dd-menu" hidden>${inner}</div>`;
|
||
box.appendChild(dd);
|
||
return { dd, btn: dd.querySelector(".dd-btn"), menu: dd.querySelector(".dd-menu"), list: dd.querySelector(".dd-list") };
|
||
};
|
||
const ddItem = (cls, inputHtml, label, extra = "") => { const row = document.createElement("label"); row.className = "dd-item" + (cls ? " " + cls : ""); row.innerHTML = `${inputHtml}<span>${esc(label)}</span>${extra}`; return row; };
|
||
// мультиселект с поиском: пустой выбор = без фильтра
|
||
function makeMulti(box, { items, selected, allLabel, onChange }) {
|
||
box.innerHTML = "";
|
||
const { dd, btn, menu, list } = ddShell(box, `<input type="search" placeholder="Поиск…" aria-label="Поиск"><div class="dd-list" role="listbox"></div><div class="dd-foot"><button type="button" class="btn all">Выбрать все</button><button type="button" class="btn clear">Снять выбор</button></div>`);
|
||
const search = dd.querySelector("input[type=search]");
|
||
const label = () => {
|
||
const sel = items.filter(i => selected.has(i.value));
|
||
btn.querySelector(".lbl").textContent = !sel.length ? allLabel : sel.length === 1 ? sel[0].label : `${sel[0].label} +${sel.length - 1}`;
|
||
btn.title = sel.map(i => i.label).join(", ");
|
||
};
|
||
const draw = () => {
|
||
const q = search.value.trim().toLowerCase(); list.innerHTML = "";
|
||
const shown = items.filter(i => !q || i.label.toLowerCase().includes(q) || (i.value || "").toLowerCase().includes(q));
|
||
if (!shown.length) { list.innerHTML = `<div class="dd-empty">ничего не найдено</div>`; return; }
|
||
shown.forEach(i => {
|
||
const row = ddItem(i.cls, `<input type="checkbox" ${selected.has(i.value) ? "checked" : ""}>`, i.label, `<span class="n">${i.count}</span>`); row.title = i.title || i.label;
|
||
row.querySelector("input").addEventListener("change", e => { if (e.target.checked) selected.add(i.value); else selected.delete(i.value); label(); onChange(); });
|
||
list.appendChild(row);
|
||
});
|
||
};
|
||
dropdown(dd, btn, menu, () => { search.value = ""; draw(); search.focus(); });
|
||
search.addEventListener("input", draw);
|
||
dd.querySelector(".all").addEventListener("click", () => { items.forEach(i => selected.add(i.value)); draw(); label(); onChange(); });
|
||
dd.querySelector(".clear").addEventListener("click", () => { selected.clear(); draw(); label(); onChange(); });
|
||
label();
|
||
return { update(next) { items = next; label(); if (!menu.hidden) draw(); } };
|
||
}
|
||
// счётчики в выпадашках: «n/всего» — n задач с этим значением проходят два других фильтра задач, всего — в файле.
|
||
// Порядок пунктов — по n, затем по всему, затем по имени
|
||
function facetItems(field, skip, labelOf, cls) {
|
||
const total = {}, shown = {};
|
||
MODEL.nodes.filter(n => !n.stub).forEach(n => { const k = field(n); total[k] = (total[k] || 0) + 1; if (passes(n, skip)) shown[k] = (shown[k] || 0) + 1; });
|
||
const keys = Object.keys(total).sort((a, b) => (a === "") - (b === "") || (shown[b] || 0) - (shown[a] || 0) || total[b] - total[a] || a.localeCompare(b, "ru"));
|
||
return { total, items: keys.map(k => ({ value: k, label: labelOf(k), title: `${labelOf(k)}: ${shown[k] || 0} из ${total[k]} задач проходят остальные фильтры`, count: `${shown[k] || 0}/${total[k]}`, cls: cls(k) + (shown[k] ? "" : " zero") })) };
|
||
}
|
||
let sprintDd = null, whoDd = null;
|
||
const sprintItems = () => facetItems(n => n.sprint || "", "sprint", k => k || "без спринта", k => (k ? "" : "none"));
|
||
const whoItems = () => facetItems(n => whoKey(n.who), "who", k => (k ? shortName(k) : "не назначен"), k => (k ? "" : "none"));
|
||
// список спринтов: по умолчанию выбран самый наполненный; «без спринта» — отдельный пункт; выбор помнится; пустой выбор = все спринты
|
||
function fillSprints() {
|
||
const box = $("sprint");
|
||
if (!MODEL.hasSprint) { box.innerHTML = `<span class="file-name">колонки «Спринт» нет — показаны все задачи</span>`; S.sprints = new Set(); sprintDd = null; return; }
|
||
const { total, items } = sprintItems();
|
||
const remembered = store.json(fileKey("sprint"), null);
|
||
S.sprints = new Set(strList(remembered).filter(n => total[n] !== undefined));
|
||
if (!S.sprints.size && !(Array.isArray(remembered) && remembered.length === 0)) { // пустой сохранённый выбор — осознанное «все спринты»
|
||
const top = Object.entries(total).filter(([n]) => n).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0];
|
||
if (top) S.sprints = new Set([top[0]]);
|
||
}
|
||
sprintDd = makeMulti(box, { items, selected: S.sprints, allLabel: "Все спринты", onChange: () => { store.set(fileKey("sprint"), JSON.stringify([...S.sprints])); refresh(); } });
|
||
}
|
||
// список исполнителей: чистый фильтр, на доске только их задачи; соседей добавляет кнопка «+ соседи по связям»
|
||
function fillWho() {
|
||
const box = $("who"), { total, items } = whoItems();
|
||
const names = Object.keys(total);
|
||
if (!names.length || (names.length === 1 && names[0] === "")) { box.innerHTML = `<span class="file-name">${names.length ? "все задачи без исполнителя" : "колонки «Исполнитель» нет"}</span>`; S.who = new Set(); whoDd = null; return; }
|
||
S.who = new Set(strList(store.json(fileKey("who"), [])).filter(w => total[w] !== undefined));
|
||
whoDd = makeMulti(box, { items, selected: S.who, allLabel: "Все исполнители", onChange: () => { store.set(fileKey("who"), JSON.stringify([...S.who])); refresh(); } });
|
||
}
|
||
const updateFacets = () => { if (sprintDd) sprintDd.update(sprintItems().items); if (whoDd) whoDd.update(whoItems().items); };
|
||
// переключатели-кнопки: значение, ключ хранилища, id кнопки
|
||
const flag = (name, key, id) => { S[name] = !S[name]; store.set(pref(key), S[name] ? "1" : "0"); setToggle(id, S[name]); };
|
||
$("neighbors").addEventListener("click", () => { flag("neighbors", "neighbors", "neighbors"); refresh(); });
|
||
$("onlyLinked").addEventListener("click", () => { flag("onlyLinked", "onlylinked", "onlyLinked"); refresh(); });
|
||
$("onlyHand").addEventListener("click", () => { flag("onlyHand", "onlyhand", "onlyHand"); refresh(); });
|
||
$("critPath").addEventListener("click", () => { flag("crit", "crit", "critPath"); refresh(); });
|
||
$("byDate").addEventListener("click", () => { flag("byDate", "bydate", "byDate"); render(); });
|
||
$("snap").addEventListener("click", () => flag("snap", "snap", "snap"));
|
||
$("probBtn").addEventListener("click", () => showProblems($("problems").hidden));
|
||
$("probClose").addEventListener("click", () => showProblems(false));
|
||
// легенда-фильтр: data-kind скрывает карточки вида работы, data-edge — стрелки типа связи
|
||
function drawLegend() {
|
||
document.querySelectorAll("#legend .lg").forEach(b => {
|
||
const off = b.dataset.kind ? S.hideKind.has(b.dataset.kind) : S.hideEdge.has(b.dataset.edge);
|
||
b.classList.toggle("off", off); b.setAttribute("aria-pressed", String(!off)); b.title = off ? "Показать снова" : "Скрыть на доске";
|
||
});
|
||
}
|
||
document.querySelectorAll("#legend .lg").forEach(b => b.addEventListener("click", () => {
|
||
const set = b.dataset.kind ? S.hideKind : S.hideEdge, v = b.dataset.kind || b.dataset.edge;
|
||
if (set.has(v)) set.delete(v); else set.add(v);
|
||
store.set(pref("hidekind"), JSON.stringify([...S.hideKind])); store.set(pref("hideedge"), JSON.stringify([...S.hideEdge]));
|
||
drawLegend(); refresh();
|
||
}));
|
||
// меню «⋯»
|
||
const more = $("more"), moreDd = dropdown(more, more.querySelector(".more-btn"), more.querySelector(".menu"));
|
||
$("tblToggle").addEventListener("click", () => { const on = $("tblBox").hidden; $("tblBox").hidden = !on; $("tblBox").open = on; setToggle("tblToggle", on); store.set(pref("tbl"), on ? "1" : "0"); });
|
||
const THEMES = ["", "light", "dark"], THEME_NAMES = { "": "системная", light: "светлая", dark: "тёмная" };
|
||
let theme = store.get(pref("theme"), ""); if (!THEMES.includes(theme)) theme = "";
|
||
const applyTheme = t => { if (t) document.documentElement.setAttribute("data-theme", t); else document.documentElement.removeAttribute("data-theme"); $("theme").textContent = "Тема: " + THEME_NAMES[t]; };
|
||
$("theme").addEventListener("click", () => { theme = THEMES[(THEMES.indexOf(theme) + 1) % THEMES.length]; store.set(pref("theme"), theme); applyTheme(theme); });
|
||
// сброс раскладки с подтверждением прямо в меню (диалоги браузера в просмотрщике артефактов не показываются)
|
||
$("reset").addEventListener("click", () => {
|
||
const wrap = $("resetWrap"), btn = $("reset");
|
||
if (wrap.querySelector(".confirm")) return;
|
||
const c = document.createElement("div"); c.className = "confirm";
|
||
c.innerHTML = `<span>Вернуть автоматическую раскладку ${S.byDate ? "режима «по датам»" : "(обычную; раскладка «по датам» останется)"}, порядок и раскрытие дорожек?</span><div class="row"><button type="button" class="btn yes">Да, сбросить</button><button type="button" class="btn no">Нет</button></div>`;
|
||
btn.hidden = true; wrap.appendChild(c);
|
||
const close = () => { c.remove(); btn.hidden = false; };
|
||
c.querySelector(".yes").addEventListener("click", () => { [posKey(), "lanes", "fold"].concat(S.byDate ? ["colw"] : []).forEach(k => store.del(fileKey(k))); close(); moreDd.close(); render(); });
|
||
c.querySelector(".no").addEventListener("click", close);
|
||
setTimeout(() => { if (c.isConnected) close(); }, 8000);
|
||
});
|
||
// высота закреплённой шапки — панель проблем липнет под ней
|
||
const headEl = document.querySelector(".sticky");
|
||
const setHeadH = () => document.documentElement.style.setProperty("--head-h", (getComputedStyle(headEl).position === "sticky" ? headEl.offsetHeight : 0) + "px");
|
||
if (window.ResizeObserver) new ResizeObserver(setHeadH).observe(headEl); window.addEventListener("resize", setHeadH);
|
||
|
||
// ===== 7. Загрузка, экспорт, запуск
|
||
function loadCsv(text, fileName) {
|
||
MODEL = fromJiraCsv(text, fileName);
|
||
$("fileName").textContent = fileName; $("fileName").title = fileName;
|
||
fillSprints(); fillWho(); refresh();
|
||
}
|
||
// импорт JSON: раскладка и фильтры кладутся в хранилище доски, так они применятся и к следующей выгрузке CSV
|
||
function loadJson(text, fileName) {
|
||
const r = fromJson(text, fileName); MODEL = r.model;
|
||
if (Object.keys(r.layout).length) store.set(fileKey("pos"), JSON.stringify(r.layout));
|
||
if (Object.keys(r.layoutByDate).length) store.set(fileKey("posdate"), JSON.stringify(r.layoutByDate));
|
||
if (r.laneOrder.length) store.set(fileKey("lanes"), JSON.stringify(r.laneOrder));
|
||
if (r.collapsedLanes) store.set(fileKey("fold"), JSON.stringify(r.collapsedLanes));
|
||
if (r.dateColumns) store.set(fileKey("colw"), JSON.stringify(r.dateColumns));
|
||
const f = r.filters;
|
||
if (Array.isArray(f.sprints)) store.set(fileKey("sprint"), JSON.stringify(strList(f.sprints)));
|
||
if (Array.isArray(f.assignees)) store.set(fileKey("who"), JSON.stringify(strList(f.assignees)));
|
||
if (f.mode === "linked") f.neighbors = true; // старые выгрузки: режим «спринт и связанные» = соседи, «все задачи файла» = пустой выбор спринта
|
||
if (f.mode === "all") store.set(fileKey("sprint"), "[]");
|
||
if (typeof f.neighbors === "boolean") { S.neighbors = f.neighbors; store.set(pref("neighbors"), f.neighbors ? "1" : "0"); setToggle("neighbors", f.neighbors); }
|
||
if (typeof f.onlyLinked === "boolean") { S.onlyLinked = f.onlyLinked; store.set(pref("onlylinked"), f.onlyLinked ? "1" : "0"); setToggle("onlyLinked", f.onlyLinked); }
|
||
if (typeof f.onlyHand === "boolean") { S.onlyHand = f.onlyHand; store.set(pref("onlyhand"), f.onlyHand ? "1" : "0"); setToggle("onlyHand", f.onlyHand); }
|
||
if (typeof f.criticalPath === "boolean") { S.crit = f.criticalPath; store.set(pref("crit"), f.criticalPath ? "1" : "0"); setToggle("critPath", f.criticalPath); }
|
||
if (typeof f.byDate === "boolean") { S.byDate = f.byDate; store.set(pref("bydate"), f.byDate ? "1" : "0"); setToggle("byDate", f.byDate); }
|
||
if (Array.isArray(f.hiddenKinds)) { S.hideKind = new Set(strList(f.hiddenKinds)); store.set(pref("hidekind"), JSON.stringify([...S.hideKind])); }
|
||
if (Array.isArray(f.hiddenEdges)) { S.hideEdge = new Set(strList(f.hiddenEdges)); store.set(pref("hideedge"), JSON.stringify([...S.hideEdge])); }
|
||
drawLegend();
|
||
$("fileName").textContent = `${MODEL.fileName} (из JSON)`; $("fileName").title = `${MODEL.fileName} — загружено из ${fileName}`;
|
||
fillSprints(); fillWho(); refresh();
|
||
}
|
||
$("file").addEventListener("change", ev => {
|
||
const f = ev.target.files && ev.target.files[0]; if (!f) return;
|
||
const rd = new FileReader();
|
||
rd.onload = () => {
|
||
const text = String(rd.result), isJson = /\.json$/i.test(f.name) || /^?\s*\{/.test(text);
|
||
try { if (isJson) loadJson(text, f.name); else loadCsv(text, f.name); }
|
||
catch (e) { $("stats").innerHTML = `<span class="warn">Не удалось разобрать ${esc(f.name)}: ${esc(e.message)}</span>`; }
|
||
};
|
||
rd.readAsText(f, "utf-8");
|
||
ev.target.value = ""; // чтобы тот же файл можно было выбрать повторно
|
||
});
|
||
// полный снимок доски: все задачи файла и связи, обе раскладки относительно дорожек (layout — обычная, layoutByDate — «по датам»; h только если задана вручную),
|
||
// порядок и свёртка дорожек, фильтры. Текущая раскладка берётся с доски (rel), другая — из хранилища
|
||
function exportJson() {
|
||
saveLayout();
|
||
const savedPos = store.json(fileKey("pos"), {}) || {}, savedDate = store.json(fileKey("posdate"), {}) || {}, vis = new Set(VIS.nodes.map(n => n.key));
|
||
const fromSaved = (saved, k) => { const r = saved[k]; if (!r) return null; const o = { x: r.x, ry: r.ry, w: r.w }; if (typeof r.h === "number") o.h = r.h; return o; };
|
||
const layout = k => fromSaved(savedPos, k), layoutByDate = k => fromSaved(savedDate, k);
|
||
const nodes = MODEL.nodes.filter(n => !n.stub).map(n => ({ key: n.key, title: n.title, status: n.status, priority: n.prio, kind: n.kind, due: n.due || null, assignee: n.who || null, sprint: n.sprint || null, epic: n.epic,
|
||
url: `${JIRA}/browse/${n.key}`, visible: vis.has(n.key), layout: layout(n.key), layoutByDate: layoutByDate(n.key) }));
|
||
const order = laneOrder();
|
||
return { format: "arm-ka-links/1", exportedAt: new Date().toISOString(), source: MODEL.fileName,
|
||
filters: { sprints: [...S.sprints], assignees: [...S.who], neighbors: S.neighbors, onlyLinked: S.onlyLinked, onlyHand: S.onlyHand, criticalPath: S.crit, byDate: S.byDate, hiddenKinds: [...S.hideKind], hiddenEdges: [...S.hideEdge] },
|
||
laneOrder: order.length ? order : VIS.lanes.map(l => l.name), collapsedLanes: [...foldedSet()], dateColumns: colWidths(),
|
||
nodes, links: MODEL.links.map(l => ({ from: l.src, to: l.dst, type: l.type })) };
|
||
}
|
||
$("exportJson").addEventListener("click", () => {
|
||
moreDd.close();
|
||
const text = JSON.stringify(exportJson(), null, 2);
|
||
$("jsonText").value = text; $("exportBox").hidden = false;
|
||
try { // в обычном браузере файл скачается; в просмотрщике артефактов скачивание не работает — остаётся текст и «Копировать»
|
||
const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([text], { type: "application/json" }));
|
||
a.download = `links-${(MODEL.fileName || "board").replace(/[^\wА-Яа-яЁё.-]+/g, "_").replace(/\.csv$/i, "")}-${new Date().toISOString().slice(0, 10)}.json`;
|
||
document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
|
||
} catch (e) {}
|
||
});
|
||
$("copyJson").addEventListener("click", () => {
|
||
const ta = $("jsonText"), btn = $("copyJson"), fallback = () => { ta.focus(); ta.select(); };
|
||
const done = () => { btn.textContent = "Скопировано"; setTimeout(() => { btn.textContent = "Копировать"; }, 1500); };
|
||
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(ta.value).then(done).catch(fallback); else fallback();
|
||
});
|
||
$("closeJson").addEventListener("click", () => { $("exportBox").hidden = true; });
|
||
|
||
// запуск: настройки из хранилища, затем встроенный пример
|
||
{ const old = store.get(pref("mode"), null); if (old !== null) { if (old === "linked") store.set(pref("neighbors"), "1"); if (old === "all") store.set(fileKey("sprint"), "[]"); store.del(pref("mode")); } } // до 11.10.2026 был режим показа
|
||
S.neighbors = store.get(pref("neighbors"), "0") === "1"; setToggle("neighbors", S.neighbors);
|
||
S.onlyLinked = store.get(pref("onlylinked"), "0") === "1"; setToggle("onlyLinked", S.onlyLinked);
|
||
S.onlyHand = store.get(pref("onlyhand"), "0") === "1"; setToggle("onlyHand", S.onlyHand);
|
||
S.crit = store.get(pref("crit"), "0") === "1"; setToggle("critPath", S.crit);
|
||
S.byDate = store.get(pref("bydate"), "0") === "1"; setToggle("byDate", S.byDate);
|
||
S.snap = store.get(pref("snap"), "1") !== "0"; setToggle("snap", S.snap);
|
||
S.hideKind = new Set(strList(store.json(pref("hidekind"), []))); S.hideEdge = new Set(strList(store.json(pref("hideedge"), []))); drawLegend();
|
||
if (store.get(pref("tbl"), "0") === "1") { $("tblBox").hidden = false; $("tblBox").open = true; setToggle("tblToggle", true); }
|
||
showProblems(store.get(pref("prob"), "0") === "1");
|
||
applyTheme(theme); setHeadH();
|
||
migrateStore();
|
||
loadCsv(DEFAULT_CSV, DEFAULT_NAME);
|
||
</script>
|