bowler/src/resources/views/charts/_grid.blade.php

211 lines
11 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-blue-50' : '' }}">{{ $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 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')
<a href="{{ route('kpis.entries.index', $item) }}" class="hover:underline">{{ $item->name }}</a>
<span class="block text-xs font-normal text-gray-400">{{ $item->thresholdSummary() }}</span>
@else
<a href="{{ route('projects.entries.index', $item) }}" class="hover:underline">{{ $item->name }}</a>
<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-blue-50/50' : '' }}">
@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-blue-400 {{ $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-blue-50/60 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-blue-50/60 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's cell to expand that month's details.
</p>
<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-blue-500'); });
}
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 (but let the entries link navigate).
var kpiRow = e.target.closest('.js-kpi-row');
if (kpiRow && !e.target.closest('a')) {
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-blue-500');
}
return;
}
// Clicks inside an open detail row keep it open; anywhere else collapses.
if (!e.target.closest('.js-detail-row')) {
collapseAll();
}
});
})();
</script>
@endif