show.php 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515
  1. <?php
  2. /** @var \App\Domain\Sprint $sprint */
  3. /** @var \App\Domain\User $currentUser */
  4. /** @var string $csrfToken */
  5. /** @var list<\App\Domain\SprintWeek> $weeks */
  6. /** @var list<\App\Domain\SprintWorker> $sprintWorkers */
  7. /** @var array<int, array<int, float>> $grid sw_id => week_id => days */
  8. /** @var array<int, array{ressourcen:float, after_reserves:float, committed_prio1:float, available:float}> $capacity */
  9. /** @var list<\App\Domain\Task> $tasks */
  10. /** @var array<int, array<int, float>> $taskGrid task_id => sw_id => days */
  11. /** @var array<int, array<int, string>> $statusGrid task_id => sw_id => status */
  12. /** @var list<\App\Domain\Worker> $ownerChoices */
  13. /** @var bool $taskStatusEnabled */
  14. use App\Domain\SprintWeek;
  15. use App\Domain\TaskAssignment;
  16. use function App\Http\e;
  17. $tasks = $tasks ?? [];
  18. $taskGrid = $taskGrid ?? [];
  19. $statusGrid = $statusGrid ?? [];
  20. $ownerChoices = $ownerChoices ?? [];
  21. $taskStatusEnabled = $taskStatusEnabled ?? false;
  22. if (!function_exists('fmt_days')) {
  23. function fmt_days(float $x): string
  24. {
  25. // Show 0 as "0", whole numbers as integer, halves as x.5
  26. if (abs($x - round($x)) < 1e-9) {
  27. return (string) (int) round($x);
  28. }
  29. return number_format($x, 1);
  30. }
  31. }
  32. ?>
  33. <section class="space-y-6"
  34. data-sprint-root
  35. data-sprint-id="<?= (int) $sprint->id ?>"
  36. data-csrf="<?= e($csrfToken) ?>"
  37. data-reserve-fraction="<?= e(number_format($sprint->reserveFraction, 4, '.', '')) ?>">
  38. <header class="flex items-end justify-between gap-4">
  39. <div>
  40. <nav class="text-xs text-slate-500 dark:text-slate-400">
  41. <a href="/" class="hover:underline">Sprints</a> /
  42. </nav>
  43. <h1 class="text-2xl font-semibold tracking-tight"><?= e($sprint->name) ?></h1>
  44. <p class="text-slate-600 mt-1 text-sm dark:text-slate-400">
  45. <?= e($sprint->startDate) ?> – <?= e($sprint->endDate) ?>
  46. · Reserve <?= e(number_format($sprint->reserveFraction * 100, 0)) ?>%
  47. <?php if ($sprint->isArchived): ?>
  48. · <span class="inline-block px-2 py-0.5 text-xs bg-slate-100 text-slate-600 rounded dark:bg-slate-700 dark:text-slate-300">archived</span>
  49. <?php endif; ?>
  50. </p>
  51. </div>
  52. <div class="flex items-center gap-3">
  53. <div data-status
  54. class="text-sm border rounded px-3 py-1 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">
  55. </div>
  56. <a href="/sprints/<?= (int) $sprint->id ?>/present"
  57. target="_blank" rel="noopener"
  58. class="inline-flex items-center gap-2 rounded-md border border-slate-300 bg-white text-slate-700 px-3 py-2 text-sm hover:bg-slate-100 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700">
  59. Present
  60. </a>
  61. <?php if ($currentUser->isAdmin): ?>
  62. <a href="/sprints/<?= (int) $sprint->id ?>/settings"
  63. class="inline-flex items-center gap-2 rounded-md border border-slate-300 bg-white text-slate-700 px-3 py-2 text-sm hover:bg-slate-100 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700">
  64. Settings
  65. </a>
  66. <?php endif; ?>
  67. </div>
  68. </header>
  69. <?php if ($sprintWorkers === [] || $weeks === []): ?>
  70. <div class="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:bg-amber-900 dark:border-amber-800 dark:text-amber-200">
  71. <?php if ($weeks === []): ?>
  72. No weeks yet. <a href="/sprints/<?= (int) $sprint->id ?>/settings" class="underline">Open settings</a> to add some.
  73. <?php elseif ($sprintWorkers === []): ?>
  74. No workers on this sprint yet. <a href="/sprints/<?= (int) $sprint->id ?>/settings" class="underline">Open settings</a> to add some.
  75. <?php endif; ?>
  76. </div>
  77. <?php else: ?>
  78. <!-- Arbeitstage grid -->
  79. <section class="rounded-lg border bg-white overflow-x-auto dark:bg-slate-800 dark:border-slate-700">
  80. <table class="min-w-full text-sm" data-arbeitstage>
  81. <thead class="bg-slate-50 text-slate-600 text-xs uppercase tracking-wider dark:bg-slate-700 dark:text-slate-300">
  82. <tr>
  83. <th class="text-left px-3 py-2 font-semibold sticky left-0 bg-slate-50 z-10 dark:bg-slate-700">&nbsp;</th>
  84. <?php foreach ($weeks as $w): ?>
  85. <th class="text-center px-2 py-2 font-semibold whitespace-nowrap">
  86. <div class="font-mono">KW<?= (int) $w->isoWeek ?></div>
  87. <div class="text-[10px] text-slate-500 font-normal dark:text-slate-400"><?= e($w->startDate) ?></div>
  88. </th>
  89. <?php endforeach; ?>
  90. <th class="text-center px-2 py-2 font-semibold">Σ</th>
  91. <th class="text-center px-2 py-2 font-semibold">RTB</th>
  92. </tr>
  93. </thead>
  94. <tbody class="divide-y divide-slate-100 dark:divide-slate-700" data-tbody>
  95. <!-- Arbeitstage row — derived from weekday selection in Sprint Settings. -->
  96. <tr class="bg-slate-50 dark:bg-slate-700">
  97. <th class="text-left px-3 py-2 font-semibold text-slate-700 sticky left-0 bg-slate-50 dark:bg-slate-700 dark:text-slate-200">
  98. Arbeitstage
  99. <?php if ($currentUser->isAdmin): ?>
  100. <a href="/sprints/<?= (int) $sprint->id ?>/settings"
  101. class="ml-1 text-[10px] font-normal text-slate-500 hover:underline dark:text-slate-400"
  102. title="Pick weekdays in Settings">(edit)</a>
  103. <?php endif; ?>
  104. </th>
  105. <?php $sumMax = 0.0; foreach ($weeks as $w): $sumMax += $w->maxWorkingDays; ?>
  106. <td class="px-1 py-1">
  107. <div class="flex items-center justify-center gap-1"
  108. data-week-arbeitstage data-week-id="<?= (int) $w->id ?>"
  109. title="<?= e(implode(' ', $w->activeDays())) ?: '—' ?>">
  110. <?php foreach (SprintWeek::DAY_LABELS as $bit => $_label): ?>
  111. <span class="inline-block h-2.5 w-2.5 rounded-full
  112. <?= $w->hasDay($_label) ? 'bg-green-500 dark:bg-green-400' : 'bg-slate-300 dark:bg-slate-600' ?>"></span>
  113. <?php endforeach; ?>
  114. </div>
  115. </td>
  116. <?php endforeach; ?>
  117. <td class="px-2 py-1 text-center font-mono font-semibold" data-sum-max>
  118. <?= e(fmt_days($sumMax)) ?>
  119. </td>
  120. <td>&nbsp;</td>
  121. </tr>
  122. <!-- One row per sprint worker -->
  123. <?php foreach ($sprintWorkers as $sw): ?>
  124. <?php $rowDays = $grid[$sw->id] ?? []; $rowSum = array_sum($rowDays); ?>
  125. <tr data-sw-row data-sw-id="<?= (int) $sw->id ?>">
  126. <th class="text-left px-3 py-2 font-medium sticky left-0 bg-white z-10 dark:bg-slate-800">
  127. <span class="flex items-center gap-2">
  128. <?php if ($currentUser->isAdmin): ?>
  129. <span class="handle cursor-grab text-slate-400 select-none dark:text-slate-500">&#8801;</span>
  130. <?php endif; ?>
  131. <?= e($sw->workerName) ?>
  132. </span>
  133. </th>
  134. <?php foreach ($weeks as $w): $v = (float) ($rowDays[$w->id] ?? 0.0); ?>
  135. <td class="px-1 py-1 text-center">
  136. <?php if ($currentUser->isAdmin): ?>
  137. <input type="number" min="0" max="5" step="0.5"
  138. value="<?= e(fmt_days($v)) ?>"
  139. data-day data-sw-id="<?= (int) $sw->id ?>"
  140. data-week-id="<?= (int) $w->id ?>"
  141. class="w-14 rounded border border-slate-200 px-1 py-1 text-center font-mono focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
  142. <?php else: ?>
  143. <span class="font-mono"><?= e(fmt_days($v)) ?></span>
  144. <?php endif; ?>
  145. </td>
  146. <?php endforeach; ?>
  147. <td class="px-2 py-1 text-center font-mono font-semibold"
  148. data-sum-days data-sw-id="<?= (int) $sw->id ?>">
  149. <?= e(fmt_days($rowSum)) ?>
  150. </td>
  151. <td class="px-1 py-1 text-center">
  152. <?php if ($currentUser->isAdmin): ?>
  153. <input type="number" min="0" max="1" step="0.05"
  154. value="<?= e(number_format($sw->rtb, 2, '.', '')) ?>"
  155. data-rtb data-sw-id="<?= (int) $sw->id ?>"
  156. class="w-16 rounded border border-slate-200 px-1 py-1 text-center font-mono focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
  157. <?php else: ?>
  158. <span class="font-mono"><?= e(number_format($sw->rtb, 2, '.', '')) ?></span>
  159. <?php endif; ?>
  160. </td>
  161. </tr>
  162. <?php endforeach; ?>
  163. </tbody>
  164. </table>
  165. </section>
  166. <!-- Capacity summary — one column per worker, aligned with task columns in Phase 6 -->
  167. <section class="rounded-lg border bg-white overflow-x-auto dark:bg-slate-800 dark:border-slate-700">
  168. <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">
  169. Capacity
  170. </div>
  171. <table class="min-w-full text-sm">
  172. <thead>
  173. <tr class="bg-slate-50 text-slate-600 text-xs dark:bg-slate-700 dark:text-slate-300">
  174. <th class="text-left px-3 py-2 font-semibold sticky left-0 bg-slate-50 z-10 dark:bg-slate-700">&nbsp;</th>
  175. <?php foreach ($sprintWorkers as $sw): ?>
  176. <th class="text-center px-2 py-2 font-semibold whitespace-nowrap">
  177. <?= e($sw->workerName) ?>
  178. </th>
  179. <?php endforeach; ?>
  180. </tr>
  181. </thead>
  182. <tbody class="divide-y divide-slate-100 dark:divide-slate-700">
  183. <tr>
  184. <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>
  185. <?php foreach ($sprintWorkers as $sw): $c = $capacity[$sw->id] ?? null; ?>
  186. <td class="px-2 py-2 text-center font-mono"
  187. data-cap-ressourcen data-sw-id="<?= (int) $sw->id ?>">
  188. <?= e(fmt_days($c['ressourcen'] ?? 0.0)) ?>
  189. </td>
  190. <?php endforeach; ?>
  191. </tr>
  192. <tr>
  193. <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>
  194. <?php foreach ($sprintWorkers as $sw): $c = $capacity[$sw->id] ?? null; ?>
  195. <td class="px-2 py-2 text-center font-mono text-slate-600 dark:text-slate-400"
  196. data-cap-after-reserves data-sw-id="<?= (int) $sw->id ?>">
  197. <?= e(fmt_days($c['after_reserves'] ?? 0.0)) ?>
  198. </td>
  199. <?php endforeach; ?>
  200. </tr>
  201. <tr>
  202. <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>
  203. <?php foreach ($sprintWorkers as $sw): $c = $capacity[$sw->id] ?? null; $av = (float) ($c['available'] ?? 0.0); ?>
  204. <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' ?>"
  205. data-cap-available data-sw-id="<?= (int) $sw->id ?>">
  206. <?= e(fmt_days($av)) ?>
  207. </td>
  208. <?php endforeach; ?>
  209. </tr>
  210. </tbody>
  211. </table>
  212. </section>
  213. <p class="text-xs text-slate-500 dark:text-slate-400">
  214. Numeric inputs snap to 0.5 (days) or 0.05 (RTB) on blur. Edits save automatically
  215. with a 400&nbsp;ms debounce; Available turns red if a worker is overcommitted.
  216. </p>
  217. <!-- Section B: Task list -->
  218. <section class="rounded-lg border bg-white overflow-hidden dark:bg-slate-800 dark:border-slate-700"
  219. data-task-section
  220. data-task-status-enabled="<?= $taskStatusEnabled ? '1' : '0' ?>">
  221. <div class="px-4 py-3 border-b bg-slate-50 flex flex-wrap items-center gap-2 dark:bg-slate-700 dark:border-slate-700">
  222. <h2 class="text-sm font-semibold text-slate-700 uppercase tracking-wider dark:text-slate-200">Tasks</h2>
  223. <!-- Toolbar -->
  224. <div class="ml-auto flex flex-wrap items-center gap-2">
  225. <!-- Reset (only visible while any filter is active — JS toggles the hidden class) -->
  226. <button type="button" data-reset-filters
  227. class="hidden rounded border border-slate-300 px-2 py-1 text-sm bg-white text-slate-700 hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700">
  228. Reset
  229. </button>
  230. <input type="search" data-task-search placeholder="Search…"
  231. class="rounded border border-slate-300 px-3 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
  232. <select data-prio-filter
  233. class="rounded border border-slate-300 px-2 py-1 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
  234. <option value="">All prios</option>
  235. <option value="1">Prio 1 only</option>
  236. <option value="2">Prio 2 only</option>
  237. </select>
  238. <!-- Multi-select owner filter -->
  239. <div class="relative" data-owner-filter-root>
  240. <button type="button" data-owner-filter-trigger
  241. class="rounded border border-slate-300 px-2 py-1 text-sm bg-white hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700">
  242. Owners <span data-owner-filter-count class="text-slate-500 dark:text-slate-400"></span>
  243. </button>
  244. <div data-owner-filter-dropdown
  245. class="hidden absolute right-0 z-20 mt-1 w-56 rounded-md border border-slate-200 bg-white shadow-lg dark:bg-slate-800 dark:border-slate-700">
  246. <div class="px-3 py-2 text-xs text-slate-500 flex items-center justify-between dark:text-slate-400">
  247. <span>Owner</span>
  248. <button type="button" data-owner-filter-clear
  249. class="text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300">Clear</button>
  250. </div>
  251. <div class="max-h-64 overflow-y-auto">
  252. <label class="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-700">
  253. <input type="checkbox" data-owner-filter-opt value="__none__"
  254. class="rounded border-slate-300 dark:border-slate-600">
  255. <span class="text-slate-500 italic dark:text-slate-400">No owner</span>
  256. </label>
  257. <?php foreach ($ownerChoices as $ow): ?>
  258. <label class="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-700">
  259. <input type="checkbox" data-owner-filter-opt value="<?= (int) $ow->id ?>"
  260. class="rounded border-slate-300 dark:border-slate-600">
  261. <span><?= e($ow->name) ?></span>
  262. </label>
  263. <?php endforeach; ?>
  264. </div>
  265. </div>
  266. </div>
  267. <?php if ($taskStatusEnabled): ?>
  268. <!-- Status filter (Phase 18) — multi-select; hides tasks
  269. whose cells are not in any of the picked states. The
  270. 'zugewiesen' (default) variant only matches cells with
  271. days > 0 so the legend doubles as a sanity check. -->
  272. <div class="relative" data-status-filter-root>
  273. <button type="button" data-status-filter-trigger
  274. class="rounded border border-slate-300 px-2 py-1 text-sm bg-white hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700">
  275. Status <span data-status-filter-count class="text-slate-500 dark:text-slate-400"></span>
  276. </button>
  277. <div data-status-filter-dropdown
  278. class="hidden absolute right-0 z-20 mt-1 w-56 rounded-md border border-slate-200 bg-white shadow-lg dark:bg-slate-800 dark:border-slate-700">
  279. <div class="px-3 py-2 text-xs text-slate-500 flex items-center justify-between dark:text-slate-400">
  280. <span>Status</span>
  281. <button type="button" data-status-filter-clear
  282. class="text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300">Clear</button>
  283. </div>
  284. <div class="max-h-64 overflow-y-auto">
  285. <?php foreach ([
  286. 'zugewiesen' => ['Zugewiesen', 'border border-slate-300 dark:border-slate-600'],
  287. 'gestartet' => ['Gestartet', 'bg-yellow-300 dark:bg-yellow-500'],
  288. 'abgeschlossen' => ['Abgeschlossen', 'bg-green-300 dark:bg-green-500'],
  289. 'abgebrochen' => ['Abgebrochen', 'bg-red-300 dark:bg-red-500'],
  290. ] as $key => $meta): ?>
  291. <label class="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-700">
  292. <input type="checkbox" data-status-filter-opt value="<?= e($key) ?>"
  293. class="rounded border-slate-300 dark:border-slate-600">
  294. <span class="inline-block h-3 w-3 rounded <?= e($meta[1]) ?>"></span>
  295. <span><?= e($meta[0]) ?></span>
  296. </label>
  297. <?php endforeach; ?>
  298. </div>
  299. </div>
  300. </div>
  301. <?php endif; ?>
  302. <!-- Focus filter — one sprint worker; hides rows where their
  303. assignment is 0 and collapses worker columns that are
  304. all-zero for the remaining rows. -->
  305. <div class="flex items-center gap-1" data-focus-filter-root>
  306. <label for="data-focus-select" class="text-xs uppercase tracking-wider text-slate-500 dark:text-slate-400">Focus</label>
  307. <select id="data-focus-select" data-focus-select
  308. class="rounded border border-slate-300 px-2 py-1 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
  309. <option value="">All workers</option>
  310. <?php foreach ($sprintWorkers as $sw): ?>
  311. <option value="<?= (int) $sw->id ?>"><?= e($sw->workerName) ?></option>
  312. <?php endforeach; ?>
  313. </select>
  314. </div>
  315. <!-- Column visibility -->
  316. <div class="relative" data-columns-root>
  317. <button type="button" data-columns-trigger
  318. class="rounded border border-slate-300 px-2 py-1 text-sm bg-white hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700">
  319. Columns
  320. </button>
  321. <div data-columns-dropdown
  322. class="hidden absolute right-0 z-20 mt-1 w-56 rounded-md border border-slate-200 bg-white shadow-lg dark:bg-slate-800 dark:border-slate-700">
  323. <div class="px-3 py-2 text-xs text-slate-500 dark:text-slate-400">Show columns</div>
  324. <div class="max-h-64 overflow-y-auto">
  325. <label class="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-700">
  326. <input type="checkbox" data-column-opt value="owner" checked class="rounded border-slate-300 dark:border-slate-600">
  327. <span>Owner</span>
  328. </label>
  329. <label class="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-700">
  330. <input type="checkbox" data-column-opt value="prio" checked class="rounded border-slate-300 dark:border-slate-600">
  331. <span>Prio</span>
  332. </label>
  333. <label class="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-700">
  334. <input type="checkbox" data-column-opt value="tot" checked class="rounded border-slate-300 dark:border-slate-600">
  335. <span>Tot</span>
  336. </label>
  337. <?php foreach ($sprintWorkers as $sw): ?>
  338. <label class="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-700">
  339. <input type="checkbox" data-column-opt value="sw-<?= (int) $sw->id ?>" checked class="rounded border-slate-300 dark:border-slate-600">
  340. <span><?= e($sw->workerName) ?></span>
  341. </label>
  342. <?php endforeach; ?>
  343. </div>
  344. </div>
  345. </div>
  346. <?php if ($currentUser->isAdmin): ?>
  347. <button type="button" data-add-task
  348. class="rounded bg-slate-900 text-white px-3 py-1 text-sm font-medium hover:bg-slate-800 dark:bg-slate-700 dark:hover:bg-slate-600">
  349. + Add task
  350. </button>
  351. <?php endif; ?>
  352. </div>
  353. </div>
  354. <div class="overflow-x-auto">
  355. <table class="min-w-full text-sm" data-task-table>
  356. <thead class="bg-slate-50 text-slate-600 text-xs uppercase tracking-wider dark:bg-slate-700 dark:text-slate-300">
  357. <tr>
  358. <th class="w-6 px-2 py-2"></th>
  359. <th class="text-left px-2 py-2 font-semibold cursor-pointer select-none"
  360. data-sort-col="title">Task <span class="sort-ind opacity-30">↕</span></th>
  361. <th class="text-left px-2 py-2 font-semibold cursor-pointer select-none"
  362. data-sort-col="owner" data-col="owner">Owner <span class="sort-ind opacity-30">↕</span></th>
  363. <th class="text-center px-2 py-2 font-semibold cursor-pointer select-none"
  364. data-sort-col="prio" data-col="prio">Prio <span class="sort-ind opacity-30">↕</span></th>
  365. <th class="text-center px-2 py-2 font-semibold cursor-pointer select-none"
  366. data-sort-col="tot" data-col="tot">Tot <span class="sort-ind opacity-30">↕</span></th>
  367. <?php foreach ($sprintWorkers as $sw): ?>
  368. <th class="text-center px-2 py-2 font-semibold cursor-pointer select-none whitespace-nowrap"
  369. data-sort-col="sw-<?= (int) $sw->id ?>" data-col="sw-<?= (int) $sw->id ?>">
  370. <?= e($sw->workerName) ?>
  371. <span class="sort-ind opacity-30">↕</span>
  372. </th>
  373. <?php endforeach; ?>
  374. <th class="w-8 px-2 py-2"></th>
  375. </tr>
  376. </thead>
  377. <tbody class="divide-y divide-slate-100 dark:divide-slate-700" data-task-tbody>
  378. <?php if ($tasks === []): ?>
  379. <tr data-empty-tasks>
  380. <td colspan="<?= 6 + count($sprintWorkers) ?>" class="px-3 py-8 text-center text-slate-500 text-sm dark:text-slate-400">
  381. No tasks yet.
  382. <?php if ($currentUser->isAdmin): ?>
  383. Click <b>+ Add task</b> to start.
  384. <?php endif; ?>
  385. </td>
  386. </tr>
  387. <?php else: ?>
  388. <?php foreach ($tasks as $t): ?>
  389. <?php $assign = $taskGrid[$t->id] ?? []; $tot = array_sum($assign); ?>
  390. <tr data-task-row
  391. data-task-id="<?= (int) $t->id ?>"
  392. data-prio="<?= (int) $t->priority ?>"
  393. data-owner="<?= $t->ownerWorkerId !== null ? (int) $t->ownerWorkerId : '' ?>"
  394. data-sort-order="<?= (int) $t->sortOrder ?>">
  395. <td class="px-2 py-1">
  396. <?php if ($currentUser->isAdmin): ?>
  397. <span class="handle cursor-grab text-slate-400 select-none dark:text-slate-500">&#8801;</span>
  398. <?php endif; ?>
  399. </td>
  400. <td class="px-2 py-1 min-w-[14rem]">
  401. <?php if ($currentUser->isAdmin): ?>
  402. <input type="text" data-title
  403. value="<?= e($t->title) ?>"
  404. class="w-full rounded border border-slate-200 px-2 py-1 focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
  405. <?php else: ?>
  406. <span><?= e($t->title) ?></span>
  407. <?php endif; ?>
  408. </td>
  409. <td class="px-2 py-1" data-col="owner">
  410. <?php if ($currentUser->isAdmin): ?>
  411. <select data-owner-select
  412. class="w-full rounded border border-slate-200 px-2 py-1 bg-white focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
  413. <option value="">—</option>
  414. <?php foreach ($ownerChoices as $ow): ?>
  415. <option value="<?= (int) $ow->id ?>" <?= $t->ownerWorkerId === $ow->id ? 'selected' : '' ?>>
  416. <?= e($ow->name) ?>
  417. </option>
  418. <?php endforeach; ?>
  419. </select>
  420. <?php else: ?>
  421. <?php
  422. $ownerName = '—';
  423. foreach ($ownerChoices as $ow) {
  424. if ($ow->id === $t->ownerWorkerId) { $ownerName = $ow->name; break; }
  425. }
  426. echo e($ownerName);
  427. ?>
  428. <?php endif; ?>
  429. </td>
  430. <td class="px-2 py-1 text-center" data-col="prio">
  431. <?php if ($currentUser->isAdmin): ?>
  432. <select data-prio-select
  433. class="rounded border border-slate-200 px-2 py-1 bg-white font-mono focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
  434. <option value="1" <?= $t->priority === 1 ? 'selected' : '' ?>>1</option>
  435. <option value="2" <?= $t->priority === 2 ? 'selected' : '' ?>>2</option>
  436. </select>
  437. <?php else: ?>
  438. <span class="font-mono"><?= (int) $t->priority ?></span>
  439. <?php endif; ?>
  440. </td>
  441. <td class="px-2 py-1 text-center font-mono font-semibold"
  442. data-col="tot" data-task-tot>
  443. <?= e(fmt_days($tot)) ?>
  444. </td>
  445. <?php foreach ($sprintWorkers as $sw):
  446. $d = (float) ($assign[$sw->id] ?? 0.0);
  447. $st = (string) ($statusGrid[$t->id][$sw->id] ?? TaskAssignment::STATUS_ZUGEWIESEN);
  448. // Phase 18: when the global flag is on, the status colour class
  449. // and data-* attributes live on the <td> itself — no nested
  450. // wrapper. Keeps the cell's table-layout intact and the days
  451. // input renders exactly as it did pre-Phase-18.
  452. $tdExtraClass = $taskStatusEnabled ? ' assign-status-' . $st : '';
  453. ?>
  454. <td class="px-1 py-1 text-center<?= e($tdExtraClass) ?>"
  455. data-col="sw-<?= (int) $sw->id ?>"
  456. <?php if ($taskStatusEnabled): ?>data-assign-cell data-status="<?= e($st) ?>" data-sw-id="<?= (int) $sw->id ?>"<?php endif; ?>
  457. data-sort-value-sw-<?= (int) $sw->id ?>="<?= e(number_format($d, 2, '.', '')) ?>">
  458. <?php if ($currentUser->isAdmin): ?>
  459. <input type="number" min="0" step="0.5"
  460. value="<?= e(fmt_days($d)) ?>"
  461. data-assign
  462. data-sw-id="<?= (int) $sw->id ?>"
  463. class="w-14 rounded border border-slate-200 px-1 py-1 text-center font-mono focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
  464. <?php else: ?>
  465. <span class="font-mono"><?= e(fmt_days($d)) ?></span>
  466. <?php endif; ?>
  467. <?php if ($taskStatusEnabled): ?>
  468. <select data-assign-status
  469. data-sw-id="<?= (int) $sw->id ?>"
  470. aria-label="Status"
  471. class="assign-status-select">
  472. <?php foreach (TaskAssignment::STATUSES as $opt): ?>
  473. <option value="<?= e($opt) ?>" <?= $opt === $st ? 'selected' : '' ?>><?= e($opt) ?></option>
  474. <?php endforeach; ?>
  475. </select>
  476. <?php endif; ?>
  477. </td>
  478. <?php endforeach; ?>
  479. <td class="px-1 py-1 text-right">
  480. <?php if ($currentUser->isAdmin): ?>
  481. <button type="button" data-delete-task
  482. class="text-sm text-red-600 hover:underline dark:text-red-400">×</button>
  483. <?php endif; ?>
  484. </td>
  485. </tr>
  486. <?php endforeach; ?>
  487. <?php endif; ?>
  488. </tbody>
  489. </table>
  490. </div>
  491. <div data-task-empty-filter class="hidden p-4 text-center text-slate-500 text-sm dark:text-slate-400">
  492. No tasks match the current filters.
  493. </div>
  494. </section>
  495. <?php endif; ?>
  496. </section>
  497. <script src="/assets/js/sprint-planner.js" defer></script>