358 lines
18 KiB
PHP
358 lines
18 KiB
PHP
{{-- Expects: $months, $groupedRows (Team => Category => rows), $showOwner (bool) --}}
|
|
@php($totalCols = count($months) + 1 + ($showOwner ? 1 : 0))
|
|
|
|
@if (empty($groupedRows))
|
|
<div class="rounded-lg border border-gray-200 bg-white px-6 py-10 text-center text-gray-500">
|
|
Nothing to show yet — KPIs and projects will appear here automatically.
|
|
</div>
|
|
@else
|
|
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white">
|
|
<table class="w-full border-collapse text-left text-sm">
|
|
<thead>
|
|
<tr class="bg-gray-50 text-xs font-semibold uppercase tracking-wide text-gray-500">
|
|
<th class="sticky left-0 z-10 min-w-48 bg-gray-50 px-4 py-3">Item</th>
|
|
@if ($showOwner)
|
|
<th class="min-w-28 px-2 py-3">Owner</th>
|
|
@endif
|
|
@foreach ($months as $month)
|
|
<th class="px-2 py-3 text-center {{ $loop->last ? 'bg-tertiary/5' : '' }}">{{ $month['label'] }}</th>
|
|
@endforeach
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-200">
|
|
@foreach ($groupedRows as $teamName => $categories)
|
|
<tr>
|
|
<td colspan="{{ $totalCols }}"
|
|
class="bg-gray-800 px-4 py-2 text-xs font-bold uppercase tracking-wider text-white">{{ $teamName }}</td>
|
|
</tr>
|
|
@foreach ($categories as $categoryName => $rows)
|
|
<tr>
|
|
<td colspan="{{ $totalCols }}"
|
|
class="bg-gray-100 px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-gray-500">{{ $categoryName }}</td>
|
|
</tr>
|
|
@foreach ($rows as $row)
|
|
@php($item = $row['item'])
|
|
<tr class="{{ $row['type'] === 'kpi' ? 'js-kpi-row' : 'js-project-row' }} cursor-pointer hover:bg-gray-50">
|
|
<td class="sticky left-0 z-10 bg-white px-4 py-2 font-medium">
|
|
@if ($row['type'] === 'kpi')
|
|
{{ $item->name }}
|
|
<span class="block text-xs font-normal text-gray-400">{{ $item->thresholdSummary() }}</span>
|
|
@else
|
|
{{ $item->name }}
|
|
<span class="block text-xs font-normal text-gray-400">Project</span>
|
|
@endif
|
|
</td>
|
|
@if ($showOwner)
|
|
<td class="px-2 py-2 text-xs text-gray-500">{{ $item->user->name }}</td>
|
|
@endif
|
|
@foreach ($months as $month)
|
|
@php($cell = $row['cells'][$month['key']] ?? null)
|
|
<td class="px-1 py-1 text-center {{ $loop->last ? 'bg-tertiary/3' : '' }}">
|
|
@if ($cell && $row['type'] === 'project')
|
|
<button type="button" title="{{ $cell['tooltip'] }}"
|
|
data-month="{{ $month['key'] }}"
|
|
data-details="{{ json_encode($cell['details']) }}"
|
|
class="js-project-cell inline-block w-full cursor-pointer rounded px-1 py-1.5 text-xs font-semibold hover:ring-2 hover:ring-secondary {{ $cell['status']->badgeClasses() }}">
|
|
{{ $cell['display'] }}
|
|
</button>
|
|
@elseif ($cell)
|
|
<span title="{{ $cell['tooltip'] }}"
|
|
class="inline-block w-full rounded px-1 py-1.5 text-xs font-semibold {{ $cell['status']->badgeClasses() }}">
|
|
{{ $cell['display'] }}
|
|
</span>
|
|
@else
|
|
<span class="text-gray-300">—</span>
|
|
@endif
|
|
</td>
|
|
@endforeach
|
|
</tr>
|
|
@if ($row['type'] === 'project')
|
|
<tr class="js-detail-row hidden">
|
|
<td colspan="{{ $totalCols }}" class="bg-tertiary/3 p-0">
|
|
<div class="detail-outer"><div class="detail-inner js-detail-content px-6 py-4"></div></div>
|
|
</td>
|
|
</tr>
|
|
@else
|
|
<tr class="js-detail-row hidden">
|
|
<td colspan="{{ $totalCols }}" class="bg-tertiary/3 p-0">
|
|
<div class="detail-outer"><div class="detail-inner js-detail-content px-6 py-4">
|
|
@include('charts._kpi-chart', ['kpi' => $item, 'chart' => $row['chart']])
|
|
</div></div>
|
|
</td>
|
|
</tr>
|
|
@endif
|
|
@endforeach
|
|
@endforeach
|
|
@endforeach
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p class="mt-2 text-xs text-gray-400">
|
|
Each cell shows the most recent entry in that month. The current month is highlighted.
|
|
Click a KPI row to expand its trend chart; click a project row for its latest details, or a specific cell for that month.
|
|
</p>
|
|
|
|
{{-- Floating note tooltip + leader line (one singleton shared by every chart on the page) --}}
|
|
<div id="kpi-note-tooltip" class="kpi-note-tooltip rounded-md border border-gray-200 bg-white shadow-lg" role="tooltip" aria-hidden="true">
|
|
<p class="border-b border-gray-100 px-3 py-1.5 text-xs font-semibold text-gray-900" data-role="title"></p>
|
|
<p class="max-h-[50vh] overflow-y-auto whitespace-pre-line px-3 py-2 text-xs text-gray-700" data-role="note"></p>
|
|
</div>
|
|
<svg class="kpi-note-leader" aria-hidden="true"><line x1="0" y1="0" x2="0" y2="0" pathLength="1" /></svg>
|
|
|
|
<script>
|
|
(function () {
|
|
function expandRow(row) {
|
|
row.classList.remove('hidden');
|
|
void row.offsetHeight; // force reflow so the 0fr -> 1fr transition runs
|
|
row.classList.add('is-open');
|
|
}
|
|
|
|
function collapseRow(row) {
|
|
row.classList.remove('is-open');
|
|
var done = false;
|
|
var finish = function () {
|
|
if (done) return;
|
|
done = true;
|
|
// Skip if the row was re-opened before the collapse finished.
|
|
if (!row.classList.contains('is-open')) {
|
|
row.classList.add('hidden');
|
|
}
|
|
};
|
|
var outer = row.querySelector('.detail-outer');
|
|
if (outer) {
|
|
outer.addEventListener('transitionend', finish, { once: true });
|
|
}
|
|
setTimeout(finish, 350); // safety net if the transition event never fires
|
|
}
|
|
|
|
function collapseAll() {
|
|
document.querySelectorAll('.js-detail-row.is-open').forEach(collapseRow);
|
|
document.querySelectorAll('.js-project-cell.ring-2').forEach(function (c) { c.classList.remove('ring-2', 'ring-secondary'); });
|
|
}
|
|
|
|
function section(label, text) {
|
|
var wrap = document.createElement('div');
|
|
var dt = document.createElement('p');
|
|
dt.className = 'text-xs font-semibold uppercase tracking-wide text-gray-500';
|
|
dt.textContent = label;
|
|
var dd = document.createElement('p');
|
|
dd.className = 'mt-0.5 whitespace-pre-line text-sm text-gray-700';
|
|
dd.textContent = text;
|
|
wrap.appendChild(dt);
|
|
wrap.appendChild(dd);
|
|
return wrap;
|
|
}
|
|
|
|
function populate(td, d) {
|
|
td.innerHTML = '';
|
|
|
|
var head = document.createElement('div');
|
|
head.className = 'mb-2 flex items-center gap-3';
|
|
var date = document.createElement('span');
|
|
date.className = 'font-semibold';
|
|
date.textContent = d.date;
|
|
var badge = document.createElement('span');
|
|
badge.className = 'inline-block rounded-full px-2.5 py-0.5 text-xs font-semibold ' + d.statusClasses;
|
|
badge.textContent = d.statusLabel;
|
|
head.appendChild(date);
|
|
head.appendChild(badge);
|
|
td.appendChild(head);
|
|
|
|
var sections = [['Notes', d.notes], ['Headwinds', d.headwinds], ['Tailwinds', d.tailwinds], ['Highlights', d.highlights], ['Blockers', d.blockers]]
|
|
.filter(function (s) { return s[1]; });
|
|
|
|
if (sections.length === 0) {
|
|
var empty = document.createElement('p');
|
|
empty.className = 'text-sm text-gray-500';
|
|
empty.textContent = 'No details were recorded for this entry.';
|
|
td.appendChild(empty);
|
|
return;
|
|
}
|
|
|
|
var grid = document.createElement('div');
|
|
grid.className = 'grid gap-x-8 gap-y-3 sm:grid-cols-2';
|
|
sections.forEach(function (s) { grid.appendChild(section(s[0], s[1])); });
|
|
td.appendChild(grid);
|
|
}
|
|
|
|
document.addEventListener('click', function (e) {
|
|
// KPI rows expand to a threshold chart.
|
|
var kpiRow = e.target.closest('.js-kpi-row');
|
|
if (kpiRow) {
|
|
var kpiDetail = kpiRow.nextElementSibling;
|
|
var kpiOpen = kpiDetail && kpiDetail.classList.contains('is-open');
|
|
collapseAll();
|
|
if (kpiDetail && !kpiOpen) {
|
|
expandRow(kpiDetail);
|
|
}
|
|
return;
|
|
}
|
|
|
|
var cell = e.target.closest('.js-project-cell');
|
|
|
|
if (cell) {
|
|
var detailRow = cell.closest('tr').nextElementSibling;
|
|
var isOpenForThisCell = detailRow
|
|
&& detailRow.classList.contains('is-open')
|
|
&& detailRow.dataset.month === cell.dataset.month;
|
|
|
|
collapseAll();
|
|
|
|
if (detailRow && !isOpenForThisCell) {
|
|
populate(detailRow.querySelector('.js-detail-content'), JSON.parse(cell.dataset.details));
|
|
detailRow.dataset.month = cell.dataset.month;
|
|
expandRow(detailRow);
|
|
cell.classList.add('ring-2', 'ring-secondary');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Project rows (outside a month cell) toggle the most recent month's details.
|
|
var projRow = e.target.closest('.js-project-row');
|
|
if (projRow) {
|
|
var projDetail = projRow.nextElementSibling;
|
|
var projOpen = projDetail && projDetail.classList.contains('is-open');
|
|
collapseAll();
|
|
if (projDetail && !projOpen) {
|
|
var cells = projRow.querySelectorAll('.js-project-cell');
|
|
var latest = cells.length ? cells[cells.length - 1] : null;
|
|
if (latest) {
|
|
populate(projDetail.querySelector('.js-detail-content'), JSON.parse(latest.dataset.details));
|
|
projDetail.dataset.month = latest.dataset.month;
|
|
expandRow(projDetail);
|
|
latest.classList.add('ring-2', 'ring-secondary');
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Clicks inside an open detail row keep it open; anywhere else collapses.
|
|
if (!e.target.closest('.js-detail-row')) {
|
|
collapseAll();
|
|
}
|
|
});
|
|
})();
|
|
|
|
// Note tooltips on chart points: hover intent shows a floating panel
|
|
// with a leader line drawn from the point. One singleton, delegated
|
|
// listeners — charts appear/disappear with row expansion unwired.
|
|
(function () {
|
|
var tip = document.getElementById('kpi-note-tooltip');
|
|
if (!tip) return;
|
|
var leader = document.querySelector('.kpi-note-leader');
|
|
var leaderLine = leader.querySelector('line');
|
|
var titleEl = tip.querySelector('[data-role="title"]');
|
|
var noteEl = tip.querySelector('[data-role="note"]');
|
|
var showTimer = null, hideTimer = null, activePoint = null;
|
|
|
|
var SHOW_DELAY = 250, HIDE_DELAY = 200, GAP = 46;
|
|
|
|
function pointCenter(group) {
|
|
var r = group.querySelector('.js-note-hit, circle').getBoundingClientRect();
|
|
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
|
}
|
|
|
|
function show(group) {
|
|
activePoint = group;
|
|
titleEl.textContent = group.dataset.title || '';
|
|
noteEl.textContent = group.dataset.note || '';
|
|
|
|
// Measure at origin, then place.
|
|
tip.style.left = '0px';
|
|
tip.style.top = '0px';
|
|
var p = pointCenter(group);
|
|
var tw = tip.offsetWidth, th = tip.offsetHeight;
|
|
|
|
// Default: panel up-and-right of the point; flip at viewport edges.
|
|
var dirX = (p.x + GAP + tw > window.innerWidth - 8) ? -1 : 1;
|
|
var dirY = (p.y - GAP - th < 8) ? 1 : -1;
|
|
var left = dirX === 1 ? p.x + GAP : p.x - GAP - tw;
|
|
var top = dirY === -1 ? p.y - GAP - th : p.y + GAP;
|
|
left = Math.max(8, Math.min(left, window.innerWidth - tw - 8));
|
|
top = Math.max(8, Math.min(top, window.innerHeight - th - 8));
|
|
tip.style.left = left + 'px';
|
|
tip.style.top = top + 'px';
|
|
|
|
// Leader runs from the point to the panel corner nearest it.
|
|
leaderLine.setAttribute('x1', p.x);
|
|
leaderLine.setAttribute('y1', p.y);
|
|
leaderLine.setAttribute('x2', dirX === 1 ? left : left + tw);
|
|
leaderLine.setAttribute('y2', dirY === -1 ? top + th : top);
|
|
|
|
leader.classList.remove('is-visible');
|
|
void leader.offsetWidth; // restart the draw-in animation
|
|
leader.classList.add('is-visible');
|
|
tip.classList.add('is-visible');
|
|
tip.setAttribute('aria-hidden', 'false');
|
|
}
|
|
|
|
function hide() {
|
|
clearTimeout(showTimer);
|
|
clearTimeout(hideTimer);
|
|
showTimer = hideTimer = null;
|
|
activePoint = null;
|
|
tip.classList.remove('is-visible');
|
|
leader.classList.remove('is-visible');
|
|
tip.setAttribute('aria-hidden', 'true');
|
|
}
|
|
|
|
function scheduleHide() {
|
|
clearTimeout(hideTimer);
|
|
hideTimer = setTimeout(hide, HIDE_DELAY);
|
|
}
|
|
|
|
// Hover intent via delegated mouseover/mouseout (they bubble; mouseenter doesn't).
|
|
document.addEventListener('mouseover', function (e) {
|
|
var group = e.target.closest && e.target.closest('.js-note-point');
|
|
if (group) {
|
|
clearTimeout(hideTimer);
|
|
if (group === activePoint) return;
|
|
clearTimeout(showTimer);
|
|
showTimer = setTimeout(function () { show(group); }, SHOW_DELAY);
|
|
return;
|
|
}
|
|
if (e.target.closest && e.target.closest('#kpi-note-tooltip')) {
|
|
clearTimeout(hideTimer); // pointer moved into the panel: keep it
|
|
}
|
|
});
|
|
|
|
document.addEventListener('mouseout', function (e) {
|
|
var from = e.target.closest && e.target.closest('.js-note-point, #kpi-note-tooltip');
|
|
if (!from) return;
|
|
var to = e.relatedTarget && e.relatedTarget.closest
|
|
&& e.relatedTarget.closest('.js-note-point, #kpi-note-tooltip');
|
|
if (to === from || (to && activePoint && to === activePoint)) return;
|
|
clearTimeout(showTimer);
|
|
showTimer = null;
|
|
if (activePoint) scheduleHide();
|
|
});
|
|
|
|
// Touch/click fallback: tapping a noted point toggles the tooltip.
|
|
// Capture phase so dismissal runs even if another handler stops propagation.
|
|
document.addEventListener('click', function (e) {
|
|
var group = e.target.closest && e.target.closest('.js-note-point');
|
|
if (group) {
|
|
e.stopPropagation(); // keep the grid's collapse handler out of it
|
|
clearTimeout(showTimer);
|
|
if (activePoint === group) {
|
|
hide();
|
|
} else {
|
|
show(group);
|
|
}
|
|
return;
|
|
}
|
|
if (!e.target.closest('#kpi-note-tooltip')) hide();
|
|
}, true);
|
|
|
|
// Fixed coordinates go stale on any scroll or resize: hide immediately.
|
|
// Scrolls inside the panel's own note body are exempt — that's the
|
|
// user reading a long note, not the page moving under the tooltip.
|
|
window.addEventListener('scroll', function (e) {
|
|
if (e.target instanceof Node && tip.contains(e.target)) return;
|
|
hide();
|
|
}, true);
|
|
window.addEventListener('resize', hide);
|
|
})();
|
|
</script>
|
|
@endif
|