Răsfoiți Sursa

Sprint view: tabs (Arbeitstage / Tasks) + smart Close on present

- /sprints/{id} now has two tabs ("Arbeitstage and capacity",
  "Capacity and tasks"). Capacity table extracted into a Twig macro
  and rendered in both panels; sprint-planner.js recompute/applyServer
  paths switched from qs to qsa so both copies update in lockstep.
  Active tab persists in localStorage (sp:{sprintId}:tab).
- /sprints/{id}/present Close button now history.back()s when
  history.length > 1, otherwise window.close()s with a /sprints/{id}
  fallback navigation if the browser blocks close.
- No schema / route / audit changes; Twig + vanilla JS only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa 3 zile în urmă
părinte
comite
28130196f1
3 a modificat fișierele cu 160 adăugiri și 69 ștergeri
  1. 78 14
      public/assets/js/sprint-planner.js
  2. 1 1
      views/sprints/present.twig
  3. 81 54
      views/sprints/show.twig

+ 78 - 14
public/assets/js/sprint-planner.js

@@ -175,16 +175,17 @@
 
         const sumEl  = qs('[data-sum-days]', row);
         if (sumEl) { sumEl.textContent = fmtDays(cap.ressourcen); }
-        const r = qs('[data-cap-ressourcen][data-sw-id="' + swId + '"]');
-        if (r) { r.textContent = fmtDays(cap.ressourcen); }
-        const a = qs('[data-cap-after-reserves][data-sw-id="' + swId + '"]');
-        if (a) { a.textContent = fmtDays(cap.afterReserves); }
-        const av = qs('[data-cap-available][data-sw-id="' + swId + '"]');
-        if (av) {
+        qsa('[data-cap-ressourcen][data-sw-id="' + swId + '"]').forEach(function (r) {
+            r.textContent = fmtDays(cap.ressourcen);
+        });
+        qsa('[data-cap-after-reserves][data-sw-id="' + swId + '"]').forEach(function (a) {
+            a.textContent = fmtDays(cap.afterReserves);
+        });
+        qsa('[data-cap-available][data-sw-id="' + swId + '"]').forEach(function (av) {
             av.textContent = fmtDays(cap.available);
             av.classList.toggle('text-red-700', cap.available < 0);
             av.classList.toggle('text-slate-900', cap.available >= 0);
-        }
+        });
     }
 
     function recomputeAllCapacity() {
@@ -198,16 +199,17 @@
         if (!perWorker || typeof perWorker !== 'object') { return; }
         Object.keys(perWorker).forEach(function (swIdStr) {
             const c = perWorker[swIdStr];
-            const r = qs('[data-cap-ressourcen][data-sw-id="' + swIdStr + '"]');
-            if (r) { r.textContent = fmtDays(c.ressourcen); }
-            const a = qs('[data-cap-after-reserves][data-sw-id="' + swIdStr + '"]');
-            if (a) { a.textContent = fmtDays(c.after_reserves); }
-            const av = qs('[data-cap-available][data-sw-id="' + swIdStr + '"]');
-            if (av) {
+            qsa('[data-cap-ressourcen][data-sw-id="' + swIdStr + '"]').forEach(function (r) {
+                r.textContent = fmtDays(c.ressourcen);
+            });
+            qsa('[data-cap-after-reserves][data-sw-id="' + swIdStr + '"]').forEach(function (a) {
+                a.textContent = fmtDays(c.after_reserves);
+            });
+            qsa('[data-cap-available][data-sw-id="' + swIdStr + '"]').forEach(function (av) {
                 av.textContent = fmtDays(c.available);
                 av.classList.toggle('text-red-700', c.available < 0);
                 av.classList.toggle('text-slate-900', c.available >= 0);
-            }
+            });
         });
     }
 
@@ -1339,4 +1341,66 @@
             }
         }
     }
+
+    // ---------------------------------------------------------------------
+    // Tabs (show.twig only) — persist active tab in localStorage
+    // ---------------------------------------------------------------------
+
+    (function initTabs() {
+        const nav = qs('[data-tab-nav]');
+        if (!nav) { return; }
+        const buttons = qsa('[data-tab-btn]', nav);
+        const panels  = qsa('[data-tab-panel]');
+        if (buttons.length === 0 || panels.length === 0) { return; }
+
+        const tabKey = 'sp:' + sprintId + ':tab' + keySuffix;
+        let active = 'arbeitstage';
+        try {
+            const stored = localStorage.getItem(tabKey);
+            if (stored && buttons.some((b) => b.getAttribute('data-tab-btn') === stored)) {
+                active = stored;
+            }
+        } catch (e) { /* private mode — ignore */ }
+
+        function activate(name) {
+            buttons.forEach(function (btn) {
+                btn.setAttribute('data-active', btn.getAttribute('data-tab-btn') === name ? 'true' : 'false');
+            });
+            panels.forEach(function (p) {
+                setHidden(p, p.getAttribute('data-tab-panel') !== name);
+            });
+            try { localStorage.setItem(tabKey, name); } catch (e) { /* ignore */ }
+        }
+
+        buttons.forEach(function (btn) {
+            btn.addEventListener('click', function () {
+                activate(String(btn.getAttribute('data-tab-btn')));
+            });
+        });
+
+        activate(active);
+    })();
+
+    // ---------------------------------------------------------------------
+    // Present view: smart Close button
+    // history.length > 1  →  go back (in-tab nav)
+    // otherwise          →  close the tab; fall back to navigation if blocked
+    // ---------------------------------------------------------------------
+
+    (function initSmartClose() {
+        const btn = qs('[data-close-present]');
+        if (!btn) { return; }
+        btn.addEventListener('click', function (ev) {
+            ev.preventDefault();
+            if (window.history.length > 1) {
+                window.history.back();
+                return;
+            }
+            const fallback = btn.getAttribute('href');
+            window.close();
+            setTimeout(function () {
+                if (!window.closed && fallback) { window.location.href = fallback; }
+            }, 100);
+        });
+    })();
 })();

+ 1 - 1
views/sprints/present.twig

@@ -22,7 +22,7 @@
             <div data-status
                  class="text-xs border rounded px-2 py-0.5 opacity-0 transition-opacity duration-200 border-slate-200 bg-slate-50 text-slate-700 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300">
             </div>
-            <a href="/sprints/{{ sprint.id }}"
+            <a href="/sprints/{{ sprint.id }}" data-close-present
                class="inline-flex items-center gap-2 rounded-md border border-slate-300 bg-white text-slate-700 px-3 py-1 text-sm hover:bg-slate-100 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700">
                 Close
             </a>

+ 81 - 54
views/sprints/show.twig

@@ -51,6 +51,74 @@
         </div>
     {% else %}
 
+        {% macro capacity_table(sprintWorkers, capacity) %}
+            <section class="rounded-lg border bg-white overflow-x-auto dark:bg-slate-800 dark:border-slate-700">
+                <div class="px-4 py-2 border-b bg-slate-50 text-xs uppercase tracking-wider text-slate-600 font-semibold dark:bg-slate-700 dark:border-slate-700 dark:text-slate-300">
+                    Capacity
+                </div>
+                <table class="min-w-full text-sm">
+                    <thead>
+                        <tr class="bg-slate-50 text-slate-600 text-xs dark:bg-slate-700 dark:text-slate-300">
+                            <th class="text-left px-3 py-2 font-semibold sticky left-0 bg-slate-50 z-10 dark:bg-slate-700">&nbsp;</th>
+                            {% for sw in sprintWorkers %}
+                                <th class="text-center px-2 py-2 font-semibold whitespace-nowrap">
+                                    {{ sw.workerName }}
+                                </th>
+                            {% endfor %}
+                        </tr>
+                    </thead>
+                    <tbody class="divide-y divide-slate-100 dark:divide-slate-700">
+                        <tr>
+                            <th class="text-left px-3 py-2 text-slate-700 font-medium sticky left-0 bg-white dark:bg-slate-800 dark:text-slate-200">Ressourcen</th>
+                            {% for sw in sprintWorkers %}
+                                {% set c = capacity[sw.id]|default(null) %}
+                                <td class="px-2 py-2 text-center font-mono"
+                                    data-cap-ressourcen data-sw-id="{{ sw.id }}">
+                                    {{ fmt_days(c.ressourcen|default(0.0)) }}
+                                </td>
+                            {% endfor %}
+                        </tr>
+                        <tr>
+                            <th class="text-left px-3 py-2 text-slate-700 font-medium sticky left-0 bg-white dark:bg-slate-800 dark:text-slate-200">− Reserven</th>
+                            {% for sw in sprintWorkers %}
+                                {% set c = capacity[sw.id]|default(null) %}
+                                <td class="px-2 py-2 text-center font-mono text-slate-600 dark:text-slate-400"
+                                    data-cap-after-reserves data-sw-id="{{ sw.id }}">
+                                    {{ fmt_days(c.after_reserves|default(0.0)) }}
+                                </td>
+                            {% endfor %}
+                        </tr>
+                        <tr>
+                            <th class="text-left px-3 py-2 text-slate-700 font-semibold sticky left-0 bg-white dark:bg-slate-800 dark:text-slate-200">Available</th>
+                            {% for sw in sprintWorkers %}
+                                {% set c = capacity[sw.id]|default(null) %}
+                                {% set av = c.available|default(0.0) %}
+                                <td class="px-2 py-2 text-center font-mono font-semibold {{ av < 0 ? 'text-red-700 dark:text-red-400' : 'text-slate-900 dark:text-slate-100' }}"
+                                    data-cap-available data-sw-id="{{ sw.id }}">
+                                    {{ fmt_days(av) }}
+                                </td>
+                            {% endfor %}
+                        </tr>
+                    </tbody>
+                </table>
+            </section>
+        {% endmacro %}
+
+        <nav class="flex border-b border-slate-200 dark:border-slate-700" role="tablist" data-tab-nav>
+            <button type="button" role="tab" data-tab-btn="arbeitstage"
+                    class="px-4 py-2 text-sm font-medium border-b-2 -mb-px focus:outline-none focus:ring-2 focus:ring-slate-400 data-[active=true]:border-slate-900 data-[active=true]:text-slate-900 data-[active=false]:border-transparent data-[active=false]:text-slate-500 data-[active=false]:hover:text-slate-700 dark:data-[active=true]:border-slate-100 dark:data-[active=true]:text-slate-100 dark:data-[active=false]:text-slate-400 dark:data-[active=false]:hover:text-slate-200"
+                    data-active="true">
+                Arbeitstage and capacity
+            </button>
+            <button type="button" role="tab" data-tab-btn="tasks"
+                    class="px-4 py-2 text-sm font-medium border-b-2 -mb-px focus:outline-none focus:ring-2 focus:ring-slate-400 data-[active=true]:border-slate-900 data-[active=true]:text-slate-900 data-[active=false]:border-transparent data-[active=false]:text-slate-500 data-[active=false]:hover:text-slate-700 dark:data-[active=true]:border-slate-100 dark:data-[active=true]:text-slate-100 dark:data-[active=false]:text-slate-400 dark:data-[active=false]:hover:text-slate-200"
+                    data-active="false">
+                Capacity and tasks
+            </button>
+        </nav>
+
+        <div data-tab-panel="arbeitstage" class="space-y-6">
+
         <section class="rounded-lg border bg-white overflow-x-auto dark:bg-slate-800 dark:border-slate-700">
             <table class="min-w-full text-sm" data-arbeitstage>
                 <thead class="bg-slate-50 text-slate-600 text-xs uppercase tracking-wider dark:bg-slate-700 dark:text-slate-300">
@@ -142,67 +210,26 @@
             </table>
         </section>
 
-        <section class="rounded-lg border bg-white overflow-x-auto dark:bg-slate-800 dark:border-slate-700">
-            <div class="px-4 py-2 border-b bg-slate-50 text-xs uppercase tracking-wider text-slate-600 font-semibold dark:bg-slate-700 dark:border-slate-700 dark:text-slate-300">
-                Capacity
-            </div>
-            <table class="min-w-full text-sm">
-                <thead>
-                    <tr class="bg-slate-50 text-slate-600 text-xs dark:bg-slate-700 dark:text-slate-300">
-                        <th class="text-left px-3 py-2 font-semibold sticky left-0 bg-slate-50 z-10 dark:bg-slate-700">&nbsp;</th>
-                        {% for sw in sprintWorkers %}
-                            <th class="text-center px-2 py-2 font-semibold whitespace-nowrap">
-                                {{ sw.workerName }}
-                            </th>
-                        {% endfor %}
-                    </tr>
-                </thead>
-                <tbody class="divide-y divide-slate-100 dark:divide-slate-700">
-                    <tr>
-                        <th class="text-left px-3 py-2 text-slate-700 font-medium sticky left-0 bg-white dark:bg-slate-800 dark:text-slate-200">Ressourcen</th>
-                        {% for sw in sprintWorkers %}
-                            {% set c = capacity[sw.id]|default(null) %}
-                            <td class="px-2 py-2 text-center font-mono"
-                                data-cap-ressourcen data-sw-id="{{ sw.id }}">
-                                {{ fmt_days(c.ressourcen|default(0.0)) }}
-                            </td>
-                        {% endfor %}
-                    </tr>
-                    <tr>
-                        <th class="text-left px-3 py-2 text-slate-700 font-medium sticky left-0 bg-white dark:bg-slate-800 dark:text-slate-200">− Reserven</th>
-                        {% for sw in sprintWorkers %}
-                            {% set c = capacity[sw.id]|default(null) %}
-                            <td class="px-2 py-2 text-center font-mono text-slate-600 dark:text-slate-400"
-                                data-cap-after-reserves data-sw-id="{{ sw.id }}">
-                                {{ fmt_days(c.after_reserves|default(0.0)) }}
-                            </td>
-                        {% endfor %}
-                    </tr>
-                    <tr>
-                        <th class="text-left px-3 py-2 text-slate-700 font-semibold sticky left-0 bg-white dark:bg-slate-800 dark:text-slate-200">Available</th>
-                        {% for sw in sprintWorkers %}
-                            {% set c = capacity[sw.id]|default(null) %}
-                            {% set av = c.available|default(0.0) %}
-                            <td class="px-2 py-2 text-center font-mono font-semibold {{ av < 0 ? 'text-red-700 dark:text-red-400' : 'text-slate-900 dark:text-slate-100' }}"
-                                data-cap-available data-sw-id="{{ sw.id }}">
-                                {{ fmt_days(av) }}
-                            </td>
-                        {% endfor %}
-                    </tr>
-                </tbody>
-            </table>
-        </section>
+        {{ _self.capacity_table(sprintWorkers, capacity) }}
 
         <p class="text-xs text-slate-500 dark:text-slate-400">
             Numeric inputs snap to 0.5 (days) or 0.05 (RTB) on blur. Edits save automatically
             with a 400&nbsp;ms debounce; Available turns red if a worker is overcommitted.
         </p>
 
-        {% include "sprints/_task_list.twig" %}
+        </div>{# /tab-panel arbeitstage #}
+
+        <div data-tab-panel="tasks" class="space-y-6 hidden">
+
+            {{ _self.capacity_table(sprintWorkers, capacity) }}
+
+            {% include "sprints/_task_list.twig" %}
+
+            {# Spacer so the task list never sits flush against the viewport
+               bottom — gives room for popovers anchored on the last row. #}
+            <div aria-hidden="true" class="min-h-[100px]"></div>
 
-        {# Spacer so the task list never sits flush against the viewport
-           bottom — gives room for popovers anchored on the last row. #}
-        <div aria-hidden="true" class="min-h-[100px]"></div>
+        </div>{# /tab-panel tasks #}
 
     {% endif %}
 </section>