OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

index.php

40 KB
<?php $this->layout('layouts/main', ['page_title' => $page_title, 'current_route' => $current_route, 'full_width' => true]); ?>
<style>
/* ── Kanban Board – Modernes Design ─────────────────────────────── */
:root {
  --kb-bg:          var(--color-gray-100);
  --kb-col-w:       300px;
  --kb-primary:     var(--color-primary);
  --kb-primary-dk:  var(--color-primary-dark);
  --kb-dark:        var(--color-gray-800);
  --kb-border:      rgba(0,0,0,.1);
  --kb-shadow:      0 1px 3px rgba(0,0,0,.12), 0 0 1px rgba(0,0,0,.08);
  --kb-radius:      0.375rem;
  --kb-prio-low:    var(--bs-teal);
  --kb-prio-normal: var(--color-primary);
  --kb-prio-high:   var(--bs-orange);
  --kb-prio-urgent: var(--bs-danger);
}

/* ── Page wrapper ────────────────────────────────────────────────── */
.kb-page {
  display: flex; flex-direction: column;
  height: calc(100vh - 57px);
}

/* ── Wrapper ─────────────────────────────────────────────────────── */
.kb-wrap {
  display: flex; flex-direction: column;
  flex: 1; min-height: 0;
  background: var(--kb-bg);
}

/* ── Filterbar ───────────────────────────────────────────────────── */
.kb-filterbar {
  display: flex; align-items: center; gap: .5rem; flex-wrap: wrap;
  padding: .625rem 1rem;
  background: var(--color-bg);
  border-bottom: 1px solid var(--color-gray-300);
  flex-shrink: 0;
}
.kb-search {
  padding: .3rem .65rem; border: 1px solid var(--color-gray-400); border-radius: var(--kb-radius);
  font-size: .82rem; width: 180px; outline: none; transition: border-color .15s;
}
.kb-search:focus { border-color: var(--kb-primary); box-shadow: 0 0 0 2px rgba(var(--color-primary-rgb), .15); }
.kb-flt-select {
  padding: .3rem .6rem; border: 1px solid var(--color-gray-400); border-radius: var(--kb-radius);
  font-size: .82rem; background: var(--color-bg); color: var(--color-gray-700); cursor: pointer; outline: none;
}
.kb-flt-select:focus { border-color: var(--kb-primary); }
.kb-flt-label { display: flex; align-items: center; gap: .3rem; font-size: .82rem; color: var(--color-gray-700); cursor: pointer; white-space: nowrap; }

/* ── Board Scroll Area ───────────────────────────────────────────── */
.kb-scroll {
  flex: 1; overflow-x: auto; overflow-y: hidden;
  padding: 1rem;
}
.kb-scroll::-webkit-scrollbar { height: 8px; }
.kb-scroll::-webkit-scrollbar-thumb { background: var(--color-gray-400); border-radius: 4px; }
.kb-board { display: flex; gap: .875rem; height: 100%; align-items: flex-start; }

/* ── Spalte ──────────────────────────────────────────────────────── */
.kb-col {
  flex: 0 0 var(--kb-col-w);
  background: var(--color-bg);
  border-radius: var(--kb-radius);
  box-shadow: var(--kb-shadow);
  border: 1px solid var(--kb-border);
  display: flex; flex-direction: column;
  height: 100%; max-height: 100%;
  overflow: hidden;
}
.kb-col-top {
  height: 4px; border-radius: var(--kb-radius) var(--kb-radius) 0 0; flex-shrink: 0;
}
.kb-col-hd {
  padding: .625rem .875rem .5rem;
  display: flex; align-items: center; gap: .4rem;
  flex-shrink: 0;
}
.kb-col-name {
  flex: 1; font-size: .875rem; font-weight: 700; color: var(--kb-dark);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.kb-col-count {
  background: var(--color-gray-200); color: var(--color-secondary);
  border-radius: 9999px; font-size: .7rem; font-weight: 700;
  padding: .1rem .5rem; flex-shrink: 0;
}
.kb-col-count.wip-warn { background: var(--alert-danger-bg); color: var(--alert-danger-text); }
.kb-col-wip {
  font-size: .68rem; color: var(--color-secondary); flex-shrink: 0; white-space: nowrap;
}
.kb-col-menu-btn {
  background: none; border: none; cursor: pointer; color: var(--color-gray-500);
  padding: .15rem .3rem; border-radius: .2rem; font-size: 1rem; line-height: 1;
  transition: all .15s; flex-shrink: 0;
}
.kb-col-menu-btn:hover { background: var(--color-gray-50); color: var(--color-gray-700); }
.kb-col-quick-add-btn {
  width: calc(100% - 1rem); margin: 0 .5rem .5rem;
  padding: .35rem .5rem; background: none;
  border: 1px dashed var(--color-gray-400); border-radius: .25rem;
  font-size: .8rem; color: var(--color-secondary); cursor: pointer; text-align: left;
  transition: all .15s; flex-shrink: 0;
}
.kb-col-quick-add-btn:hover { background: var(--color-gray-50); border-color: var(--color-gray-500); color: var(--color-gray-800); }

/* ── Task-Liste ──────────────────────────────────────────────────── */
.kb-col-body {
  flex: 1; overflow-y: auto; padding: .375rem .5rem;
  display: flex; flex-direction: column; gap: .375rem;
  min-height: 48px;
}
.kb-col-body::-webkit-scrollbar { width: 4px; }
.kb-col-body::-webkit-scrollbar-thumb { background: var(--color-gray-300); border-radius: 2px; }
.kb-col.drag-over .kb-col-body {
  background: rgba(var(--color-primary-rgb), .07);
  outline: 2px dashed var(--kb-primary);
  outline-offset: -2px; border-radius: .2rem;
}
.kb-card.drag-target-before {
  border-top: 2px solid var(--kb-primary);
  margin-top: 2px;
}

/* ── Task-Karte ──────────────────────────────────────────────────── */
.kb-card {
  background: var(--color-bg); border-radius: .3rem;
  border: 1px solid rgba(0,0,0,.09);
  border-left-width: 4px;
  padding: .55rem .65rem .5rem;
  cursor: pointer; user-select: none;
  box-shadow: 0 1px 2px rgba(0,0,0,.06);
  transition: box-shadow .15s, border-color .15s, opacity .2s;
  position: relative;
}
.kb-card:hover { box-shadow: 0 2px 6px rgba(0,0,0,.12); border-top-color: rgba(0,0,0,.15); }
.kb-card:active { cursor: grabbing; }
.kb-card.dragging { opacity: .35; }
.kb-card--done { opacity: .55; }
.kb-card--done .kb-card-title { text-decoration: line-through; color: var(--color-secondary); }
.kb-card--hidden { display: none; }

.kb-prio-low    { border-left-color: var(--kb-prio-low); }
.kb-prio-normal { border-left-color: var(--kb-prio-normal); }
.kb-prio-high   { border-left-color: var(--kb-prio-high); }
.kb-prio-urgent { border-left-color: var(--kb-prio-urgent); }

.kb-card-hd     { display: flex; align-items: flex-start; gap: .4rem; margin-bottom: .3rem; }
.kb-card-title  { flex: 1; font-size: .84rem; font-weight: 600; color: var(--color-gray-900); line-height: 1.35; }
.kb-done-btn {
  flex-shrink: 0; width: 1.15rem; height: 1.15rem; border-radius: 50%;
  border: 2px solid var(--color-gray-400); background: var(--color-bg); cursor: pointer;
  display: flex; align-items: center; justify-content: center;
  font-size: .6rem; color: transparent; transition: all .15s; padding: 0;
  margin-top: .1rem;
}
.kb-done-btn:hover { border-color: var(--bs-success); color: var(--bs-success); }
.kb-done-btn--on { border-color: var(--bs-success); background: var(--bs-success); color: var(--color-on-accent); }
.kb-done-btn--on:hover { border-color: var(--bs-success-dark); background: var(--bs-success-dark); }

.kb-card-meta { display: flex; align-items: center; gap: .3rem; flex-wrap: wrap; }
.kb-label { display: inline-block; padding: .1rem .45rem; border-radius: 9999px; font-size: .68rem; font-weight: 700; color: var(--color-on-accent); }
.kb-prio-badge { font-size: .68rem; font-weight: 700; padding: .1rem .4rem; border-radius: 9999px; }
.prio-low-b    { background: var(--alert-success-bg); color: var(--alert-success-text); }
.prio-normal-b { background: var(--alert-info-bg); color: var(--alert-info-text); }
.prio-high-b   { background: var(--alert-warning-bg); color: var(--alert-warning-text); }
.prio-urgent-b { background: var(--alert-danger-bg); color: var(--alert-danger-text); }
.kb-due { font-size: .7rem; color: var(--color-secondary); }
.kb-due.overdue { color: var(--bs-danger); font-weight: 700; }
.kb-avatars { display: flex; gap: .15rem; margin-left: auto; }
.kb-avatar {
  width: 1.4rem; height: 1.4rem; border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  font-size: .58rem; font-weight: 700; color: var(--color-on-accent);
  border: 1.5px solid var(--color-bg); flex-shrink: 0;
}

/* ── Quick-Add Form ──────────────────────────────────────────────── */
.kb-quick-form {
  display: none; padding: 0 .5rem .5rem; flex-shrink: 0;
}
.kb-quick-form.open { display: block; }
.kb-quick-input {
  width: 100%; padding: .35rem .5rem; border: 1px solid var(--kb-primary);
  border-radius: .25rem; font-size: .82rem; box-sizing: border-box;
  margin-bottom: .3rem; outline: none; font-family: inherit;
}
.kb-quick-row { display: flex; gap: .3rem; }

/* ── Neue Spalte Button ──────────────────────────────────────────── */
.kb-add-col {
  flex: 0 0 240px;
  background: rgba(255,255,255,.6); border: 2px dashed var(--color-gray-400);
  border-radius: var(--kb-radius); cursor: pointer;
  display: flex; align-items: center; justify-content: center;
  min-height: 80px; font-size: .85rem; color: var(--color-secondary); font-weight: 600;
  transition: all .15s; align-self: flex-start; padding: 1rem;
}
.kb-add-col:hover { background: rgba(255,255,255,.9); border-color: var(--color-gray-500); color: var(--color-gray-800); }

/* ── Modals: Basis kommt aus app.css (.modal-overlay/.modal-card),
   hier nur Zustands-Toggle und Kanban-spezifische Maße ── */
.kb-overlay { display: none; z-index: 9000; }
.kb-overlay.open { display: flex; }
#kb-col-overlay .modal-card { max-width: 380px; }
.kb-overlay textarea.form-control { resize: vertical; }
.kb-overlay .modal-body > .form-group:last-child { margin-bottom: 0; }
.kb-modal-ft-left { margin-right: auto; }
.kb-two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 0 .75rem; }
.kb-assignee-list {
  display: flex; flex-wrap: wrap; gap: .3rem;
  max-height: 110px; overflow-y: auto;
  border: 1px solid var(--color-gray-300); border-radius: .25rem; padding: .4rem;
}
.kb-assignee-item { display: flex; align-items: center; gap: .3rem; font-size: .8rem; white-space: nowrap; }
.kb-color-swatches { display: flex; gap: .4rem; flex-wrap: wrap; }
.kb-swatch {
  width: 1.6rem; height: 1.6rem; border-radius: 50%; cursor: pointer;
  border: 2px solid transparent; transition: transform .1s;
}
.kb-swatch:hover { transform: scale(1.15); }
.kb-swatch.active { border-color: var(--kb-dark); }

/* ── Responsive ──────────────────────────────────────────────────── */
@media (max-width: 640px) {
  .kb-search { width: 120px; }
}
</style>

<?php
// Tasks nach Spalte gruppieren
$tasksByCol = [];
foreach ($tasks as $t) {
    $tasksByCol[(int)$t['column_id']][] = $t;
}

// Avatar-Farben (Palette)
$avatarColors = ['#3c8dbc','#00a65a','#dd4b39','#f39c12','#6f42c1','#e83e8c','#20c997','#fd7e14'];
function kbAvatarColor(int $uid, array $colors): string {
    return $colors[$uid % count($colors)];
}
function kbInitials(string $name): string {
    $parts = preg_split('/\s+/', trim($name));
    if (count($parts) >= 2) return mb_strtoupper(mb_substr($parts[0],0,1) . mb_substr(end($parts),0,1));
    return mb_strtoupper(mb_substr($name,0,2));
}
$today = date('Y-m-d');
?>

<div class="kb-page">

<div class="content-header">
  <div>
    <h1><?= $this->te('Kanban') ?></h1>
    <ol class="breadcrumb">
      <li class="breadcrumb-item"><a href="/"><?= $this->te('Home') ?></a></li>
      <li class="breadcrumb-item active"><?= $this->te('Kanban') ?></li>
    </ol>
  </div>
  <div class="content-header-actions">
    <?php if ($can_manage): ?>
    <a href="/kanban/categories" class="btn btn-default"><?= $this->te('Labels') ?></a>
    <button class="btn btn-default" onclick="kbOpenColModal(null)"><?= $this->te('+ Spalte') ?></button>
    <button class="btn btn-primary" onclick="kbOpenTaskModal({})">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
        <?= $this->te('Aufgabe') ?>
    </button>
    <?php endif; ?>
  </div>
</div>

<div class="kb-wrap">

  <!-- ── Filterbar ────────────────────────────────────────────────── -->
  <div class="kb-filterbar">
    <input type="search" class="kb-search" id="kb-search" placeholder="<?= $this->te('Suchen...') ?>" oninput="kbFilter()">
    <select class="kb-flt-select" id="kb-flt-prio" onchange="kbFilter()">
      <option value=""><?= $this->te('Alle Prioritäten') ?></option>
      <option value="urgent"><?= $this->te('Dringend') ?></option>
      <option value="high"><?= $this->te('Hoch') ?></option>
      <option value="normal"><?= $this->te('Normal') ?></option>
      <option value="low"><?= $this->te('Niedrig') ?></option>
    </select>
    <select class="kb-flt-select" id="kb-flt-cat" onchange="kbFilter()">
      <option value="0"><?= $this->te('Alle Labels') ?></option>
      <?php foreach ($categories as $cat): ?>
      <option value="<?= (int)$cat['id'] ?>"><?= htmlspecialchars($cat['name']) ?></option>
      <?php endforeach; ?>
    </select>
    <label class="kb-flt-label">
      <input type="checkbox" id="kb-flt-open" onchange="kbFilter()"> <?= $this->te('Nur offen') ?>
    </label>
  </div>

  <!-- ── Board ───────────────────────────────────────────────────── -->
  <div class="kb-scroll">
    <div class="kb-board" id="kb-board">

    <?php foreach ($columns as $col):
        $colId    = (int)$col['id'];
        $colColor = htmlspecialchars($col['color'] ?: '#6366f1');
        $colName  = htmlspecialchars($col['name']);
        $wipLimit = $col['wip_limit'] ? (int)$col['wip_limit'] : null;
        $colTasks = $tasksByCol[$colId] ?? [];
        $taskCount = count($colTasks);
        $wipWarn  = $wipLimit !== null && $taskCount > $wipLimit;
    ?>
    <div class="kb-col" id="kb-col-<?= $colId ?>"
         data-col-id="<?= $colId ?>"
         ondragover="kbDragOver(event,this)"
         ondragleave="kbDragLeave(this)"
         ondrop="kbDrop(event,this)">

      <div class="kb-col-top" style="background:<?= $colColor ?>"></div>

      <div class="kb-col-hd">
        <span class="kb-col-name" title="<?= $colName ?>"><?= $colName ?></span>
        <span class="kb-col-count <?= $wipWarn ? 'wip-warn' : '' ?>" id="kb-count-<?= $colId ?>"><?= $taskCount ?></span>
        <?php if ($wipLimit): ?>
        <span class="kb-col-wip">/<?= $wipLimit ?></span>
        <?php endif; ?>
        <?php if ($can_manage): ?>
        <button class="kb-col-menu-btn" title="<?= $this->te('Spalte bearbeiten') ?>"
                onclick="kbOpenColModal(<?= htmlspecialchars(json_encode(['id'=>$col['id'],'name'=>$col['name'],'color'=>$col['color'],'wip_limit'=>$col['wip_limit']])) ?>)">&#8942;</button>
        <?php endif; ?>
      </div>

      <div class="kb-col-body" id="kb-body-<?= $colId ?>">
        <?php foreach ($colTasks as $task):
            $tid      = (int)$task['id'];
            $prio     = htmlspecialchars($task['priority'] ?? 'normal');
            $isDone   = (bool)($task['done'] ?? false);
            $dueDate  = $task['due_date'] ?? '';
            $isOverdue = $dueDate && $dueDate < $today && !$isDone;
            $dueFmt   = $dueDate ? date('d.m.Y', strtotime($dueDate)) : '';
            $assigneeNames = $task['assignee_names'] ? explode('|||', $task['assignee_names']) : [];
            $assigneeIds   = $task['assignee_ids']   ? explode(',',   $task['assignee_ids'])   : [];
            $taskJson = htmlspecialchars(json_encode([
                'id'          => $task['id'],
                'title'       => $task['title'],
                'description' => $task['description'] ?? '',
                'column_id'   => $task['column_id'],
                'category_id' => $task['category_id'],
                'priority'    => $task['priority'],
                'due_date'    => $task['due_date'] ?? '',
                'done'        => (bool)$task['done'],
                'assignee_ids'=> $assigneeIds,
            ]), ENT_QUOTES);
        ?>
        <div class="kb-card kb-prio-<?= $prio ?> <?= $isDone ? 'kb-card--done' : '' ?>"
             id="kb-task-<?= $tid ?>"
             data-task-id="<?= $tid ?>"
             data-col-id="<?= $colId ?>"
             data-prio="<?= $prio ?>"
             data-cat="<?= (int)($task['category_id'] ?? 0) ?>"
             data-done="<?= $isDone ? '1' : '0' ?>"
             data-title="<?= htmlspecialchars(strtolower($task['title'])) ?>"
             draggable="true"
             ondragstart="kbDragStart(event,this)"
             ondragend="kbDragEnd(this)"
             onclick="kbOpenTaskModal(<?= $taskJson ?>)">

          <div class="kb-card-hd">
            <?php if (true): // always show done btn ?>
            <button class="kb-done-btn <?= $isDone ? 'kb-done-btn--on' : '' ?>"
                    data-id="<?= $tid ?>" title="<?= $isDone ? 'Als offen markieren' : 'Erledigt' ?>"
                    onclick="event.stopPropagation();kbToggleDone(<?= $tid ?>)">&#10003;</button>
            <?php endif; ?>
            <span class="kb-card-title"><?= htmlspecialchars($task['title']) ?></span>
          </div>

          <div class="kb-card-meta">
            <?php if (!empty($task['category_name'])): ?>
            <span class="kb-label" style="background:<?= htmlspecialchars($task['category_color'] ?: 'var(--indigo-500)') ?>"><?= htmlspecialchars($task['category_name']) ?></span>
            <?php endif; ?>
            <span class="kb-prio-badge prio-<?= $this->e($prio) ?>-b"><?php
              $prioLabels = ['low'=>'Niedrig','normal'=>'Normal','high'=>'Hoch','urgent'=>'Dringend'];
              echo $prioLabels[$prio] ?? $this->e($prio);
            ?></span>
            <?php if ($dueFmt): ?>
            <span class="kb-due <?= $isOverdue ? 'overdue' : '' ?>">&#9201; <?= $dueFmt ?></span>
            <?php endif; ?>
            <?php if (!empty($assigneeNames)): ?>
            <span class="kb-avatars">
              <?php foreach (array_slice($assigneeNames, 0, 3) as $ai => $aname): ?>
              <span class="kb-avatar" style="background:<?= kbAvatarColor((int)($assigneeIds[$ai] ?? $ai), $avatarColors) ?>" title="<?= htmlspecialchars($aname) ?>"><?= kbInitials($aname) ?></span>
              <?php endforeach; ?>
              <?php if (count($assigneeNames) > 3): ?>
              <span class="kb-avatar" style="background:var(--color-secondary)">+<?= count($assigneeNames)-3 ?></span>
              <?php endif; ?>
            </span>
            <?php endif; ?>
          </div>
        </div>
        <?php endforeach; ?>
      </div><!-- /.kb-col-body -->

      <?php if ($can_manage): ?>
      <div class="kb-quick-form" id="kb-qf-<?= $colId ?>">
        <input class="kb-quick-input" id="kb-qi-<?= $colId ?>" type="text" placeholder="<?= $this->te('Aufgabe hinzufügen…') ?>"
               onkeydown="if(event.key==='Enter')kbQuickSubmit(<?= $colId ?>);if(event.key==='Escape')kbQuickClose(<?= $colId ?>)">
        <div class="kb-quick-row">
          <button class="btn btn-primary btn-sm" onclick="kbQuickSubmit(<?= $colId ?>)"><?= $this->te('Hinzufügen') ?></button>
          <button class="btn btn-default btn-sm" onclick="kbQuickClose(<?= $colId ?>)"><?= $this->te('Abbrechen') ?></button>
        </div>
      </div>
      <button class="kb-col-quick-add-btn" id="kb-qb-<?= $colId ?>" onclick="kbQuickOpen(<?= $colId ?>)"><?= $this->te('&#43; Aufgabe hinzufügen') ?></button>
      <?php endif; ?>

    </div><!-- /.kb-col -->
    <?php endforeach; ?>

    <?php if ($can_manage): ?>
    <div class="kb-add-col" onclick="kbOpenColModal(null)"><?= $this->te('&#43; Neue Spalte') ?></div>
    <?php endif; ?>

    </div><!-- /#kb-board -->
  </div><!-- /.kb-scroll -->
</div><!-- /.kb-wrap -->
</div><!-- /.kb-page -->

<!-- ── Task-Modal ─────────────────────────────────────────────────── -->
<div class="modal-overlay kb-overlay" id="kb-task-overlay" onclick="if(event.target===this)kbCloseTaskModal()">
  <div class="modal-card" role="dialog" aria-modal="true">
    <div class="modal-header">
      <h2 id="kb-modal-title-lbl"><?= $this->te('Aufgabe') ?></h2>
      <button aria-label="<?= $this->te('Schließen') ?>" type="button" class="modal-close" onclick="kbCloseTaskModal()">&times;</button>
    </div>
    <div class="modal-body">
      <input type="hidden" id="kb-modal-id">
      <div class="form-group">
        <label for="kb-modal-title"><?= $this->te('Titel') ?> <span class="text-danger">*</span></label>
        <input class="form-control" id="kb-modal-title" type="text" placeholder="<?= $this->te('Aufgabentitel') ?>" maxlength="500">
      </div>
      <div class="form-group">
        <label for="kb-modal-desc"><?= $this->te('Beschreibung') ?></label>
        <textarea class="form-control" id="kb-modal-desc" placeholder="<?= $this->te('Optional…') ?>" rows="4"></textarea>
      </div>
      <div class="kb-two-col">
        <div class="form-group">
          <label for="kb-modal-col"><?= $this->te('Spalte') ?></label>
          <select class="form-control" id="kb-modal-col">
            <?php foreach ($columns as $col): ?>
            <option value="<?= (int)$col['id'] ?>"><?= htmlspecialchars($col['name']) ?></option>
            <?php endforeach; ?>
          </select>
        </div>
        <div class="form-group">
          <label for="kb-modal-prio"><?= $this->te('Priorität') ?></label>
          <select class="form-control" id="kb-modal-prio">
            <option value="low"><?= $this->te('Niedrig') ?></option>
            <option value="normal"><?= $this->te('Normal') ?></option>
            <option value="high"><?= $this->te('Hoch') ?></option>
            <option value="urgent"><?= $this->te('Dringend') ?></option>
          </select>
        </div>
      </div>
      <div class="kb-two-col">
        <div class="form-group">
          <label for="kb-modal-cat"><?= $this->te('Label') ?></label>
          <select class="form-control" id="kb-modal-cat">
            <option value=""><?= $this->te('— kein Label —') ?></option>
            <?php foreach ($categories as $cat): ?>
            <option value="<?= (int)$cat['id'] ?>"><?= htmlspecialchars($cat['name']) ?></option>
            <?php endforeach; ?>
          </select>
        </div>
        <div class="form-group">
          <label for="kb-modal-due"><?= $this->te('Fälligkeitsdatum') ?></label>
          <input class="form-control" id="kb-modal-due" type="date">
        </div>
      </div>
      <?php if (!empty($users)): ?>
      <div class="form-group">
        <label><?= $this->te('Bearbeiter') ?></label>
        <div class="kb-assignee-list">
          <?php foreach ($users as $u): ?>
          <label class="kb-assignee-item">
            <input type="checkbox" class="kb-modal-assignee" value="<?= (int)$u['id'] ?>">
            <?= htmlspecialchars($u['display_name']) ?>
          </label>
          <?php endforeach; ?>
        </div>
      </div>
      <?php endif; ?>
    </div>
    <div class="modal-footer">
      <?php if ($can_manage): ?>
      <div class="kb-modal-ft-left">
        <button type="button" class="btn btn-danger btn-sm" id="kb-modal-del-btn" onclick="kbDeleteTask()" style="display:none"><?= $this->te('&#128465; Löschen') ?></button>
      </div>
      <button type="button" class="btn btn-default" onclick="kbCloseTaskModal()"><?= $this->te('Abbrechen') ?></button>
      <button type="button" class="btn btn-primary" id="kb-modal-save-btn" onclick="kbSaveTask()"><?= $this->te('Speichern') ?></button>
      <?php else: ?>
      <button type="button" class="btn btn-default" onclick="kbCloseTaskModal()"><?= $this->te('Schließen') ?></button>
      <?php endif; ?>
    </div>
  </div>
</div>

<!-- ── Spalten-Modal ───────────────────────────────────────────────── -->
<?php if ($can_manage): ?>
<div class="modal-overlay kb-overlay" id="kb-col-overlay" onclick="if(event.target===this)kbCloseColModal()">
  <div class="modal-card" role="dialog" aria-modal="true">
    <div class="modal-header">
      <h2 id="kb-col-modal-title"><?= $this->te('Spalte') ?></h2>
      <button aria-label="<?= $this->te('Schließen') ?>" type="button" class="modal-close" onclick="kbCloseColModal()">&times;</button>
    </div>
    <div class="modal-body">
      <input type="hidden" id="kb-col-id">
      <div class="form-group">
        <label for="kb-col-name-inp"><?= $this->te('Name') ?> <span class="text-danger">*</span></label>
        <input class="form-control" id="kb-col-name-inp" type="text" maxlength="255">
      </div>
      <div class="form-group">
        <label><?= $this->te('Farbe') ?></label>
        <div class="kb-color-swatches" id="kb-col-swatches">
          <?php foreach (['#6c757d','#3c8dbc','#00a65a','#fd7e14','#dc3545','#f39c12','#6f42c1','#e83e8c','#20c997','#17a2b8','#343a40','#6366f1'] as $sc): ?>
          <span class="kb-swatch" style="background:<?= $sc ?>" data-color="<?= $sc ?>" onclick="kbSelectColor('<?= $sc ?>')"></span>
          <?php endforeach; ?>
        </div>
        <input type="hidden" id="kb-col-color" value="#6c757d">
      </div>
      <div class="form-group">
        <label for="kb-col-wip"><?= $this->te('WIP-Limit') ?></label>
        <input class="form-control" id="kb-col-wip" type="number" min="1" max="999" placeholder="z.B. 5">
        <span class="form-hint"><?= $this->te('Leer = unbegrenzt.') ?></span>
      </div>
    </div>
    <div class="modal-footer">
      <div class="kb-modal-ft-left">
        <button type="button" class="btn btn-danger btn-sm" id="kb-col-del-btn" onclick="kbDeleteColumn()" style="display:none"><?= $this->te('&#128465; Löschen') ?></button>
      </div>
      <button type="button" class="btn btn-default" onclick="kbCloseColModal()"><?= $this->te('Abbrechen') ?></button>
      <button type="button" class="btn btn-primary" onclick="kbSaveColumn()"><?= $this->te('Speichern') ?></button>
    </div>
  </div>
</div>
<?php endif; ?>

<script>
'use strict';

var CSRF = (document.querySelector('meta[name="csrf-token"]') || {content:''}).content;
var canManage = <?php echo $can_manage ? 'true' : 'false'; ?>;

/* ── AJAX helpers ──────────────────────────────────────────────── */
function post(url, data) {
    var body = new URLSearchParams();
    body.append('csrf_token', CSRF);
    for (var k in data) {
        var v = data[k];
        if (Array.isArray(v)) { v.forEach(function(vi){ body.append(k, vi); }); }
        else { body.append(k, v == null ? '' : v); }
    }
    return fetch(url, {
        method: 'POST',
        headers: {'Content-Type':'application/x-www-form-urlencoded','X-Requested-With':'XMLHttpRequest','X-CSRF-Token':CSRF},
        body: body.toString()
    }).then(function(r){ return r.json(); }).catch(function(){ return {error:'Netzwerkfehler'}; });
}
function postJson(url, obj) {
    obj._csrf = CSRF;
    return fetch(url, {
        method: 'POST',
        headers: {'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest','X-CSRF-Token':CSRF},
        body: JSON.stringify(obj)
    }).then(function(r){ return r.json(); }).catch(function(){ return {error:'Netzwerkfehler'}; });
}

/* ── Filter ────────────────────────────────────────────────────── */
function kbFilter() {
    var search  = (document.getElementById('kb-search').value || '').toLowerCase();
    var prio    = document.getElementById('kb-flt-prio').value;
    var cat     = document.getElementById('kb-flt-cat').value;
    var onlyOpen= document.getElementById('kb-flt-open').checked;

    document.querySelectorAll('.kb-card').forEach(function(card) {
        var ok = true;
        if (search && card.dataset.title.indexOf(search) < 0) ok = false;
        if (prio && card.dataset.prio !== prio) ok = false;
        if (cat && cat !== '0' && card.dataset.cat !== cat) ok = false;
        if (onlyOpen && card.dataset.done === '1') ok = false;
        card.classList.toggle('kb-card--hidden', !ok);
    });
}

/* ── Drag & Drop ───────────────────────────────────────────────── */
var _dragId = null;
var _dragSrcCol = null;
var _dragTargetCard = null;

function kbDragStart(e, card) {
    _dragId = parseInt(card.dataset.taskId);
    _dragSrcCol = parseInt(card.dataset.colId);
    card.classList.add('dragging');
    e.dataTransfer.effectAllowed = 'move';
}
function kbDragEnd(card) {
    card.classList.remove('dragging');
    document.querySelectorAll('.kb-col.drag-over').forEach(function(c){ c.classList.remove('drag-over'); });
    document.querySelectorAll('.kb-card.drag-target-before').forEach(function(c){ c.classList.remove('drag-target-before'); });
    _dragTargetCard = null;
}
function kbDragOver(e, col) {
    e.preventDefault();
    e.dataTransfer.dropEffect = 'move';
    col.classList.add('drag-over');

    // Detect card-level hover for within-column reordering
    document.querySelectorAll('.kb-card.drag-target-before').forEach(function(c){ c.classList.remove('drag-target-before'); });
    var target = e.target ? e.target.closest('.kb-card') : null;
    if (target && target.dataset.taskId && parseInt(target.dataset.taskId) !== _dragId) {
        target.classList.add('drag-target-before');
        _dragTargetCard = target;
    } else {
        _dragTargetCard = null;
    }
}
function kbDragLeave(col) {
    col.classList.remove('drag-over');
}
function kbDrop(e, col) {
    e.preventDefault();
    col.classList.remove('drag-over');
    document.querySelectorAll('.kb-card.drag-target-before').forEach(function(c){ c.classList.remove('drag-target-before'); });

    if (!_dragId || !canManage) return;
    var targetColId = parseInt(col.dataset.colId);
    var card = document.getElementById('kb-task-' + _dragId);
    var body = document.getElementById('kb-body-' + targetColId);
    if (!card || !body) return;

    if (targetColId !== _dragSrcCol) {
        // Move to different column
        if (_dragTargetCard) {
            body.insertBefore(card, _dragTargetCard);
        } else {
            body.appendChild(card);
        }
        card.dataset.colId = targetColId;
        updateCount(_dragSrcCol, -1);
        updateCount(targetColId, 1);
        postJson('/kanban/tasks/' + _dragId + '/move', {column_id: targetColId}).then(function(d){
            if (d.error) { alert(d.error); location.reload(); }
        });
    } else {
        // Reorder within same column
        if (_dragTargetCard && _dragTargetCard !== card) {
            body.insertBefore(card, _dragTargetCard);
            kbSaveCardOrder(targetColId);
        }
    }

    _dragId = null; _dragSrcCol = null; _dragTargetCard = null;
}
function updateCount(colId, delta) {
    var el = document.getElementById('kb-count-' + colId);
    if (el) el.textContent = Math.max(0, parseInt(el.textContent || '0') + delta);
}
function kbSaveCardOrder(colId) {
    var body = document.getElementById('kb-body-' + colId);
    if (!body) return;
    var order = [];
    body.querySelectorAll('.kb-card[data-task-id]').forEach(function(c){
        order.push(parseInt(c.dataset.taskId));
    });
    postJson('/kanban/tasks/reorder', {order: order}).then(function(d){
        if (d.error) { console.error('Sortierung konnte nicht gespeichert werden:', d.error); }
    });
}

/* ── Quick-Add ─────────────────────────────────────────────────── */
function kbQuickOpen(colId) {
    var form = document.getElementById('kb-qf-' + colId);
    var btn  = document.getElementById('kb-qb-' + colId);
    var inp  = document.getElementById('kb-qi-' + colId);
    if (!form) return;
    form.classList.add('open');
    if (btn) btn.style.display = 'none';
    if (inp) { inp.value = ''; inp.focus(); }
}
function kbQuickClose(colId) {
    var form = document.getElementById('kb-qf-' + colId);
    var btn  = document.getElementById('kb-qb-' + colId);
    if (form) form.classList.remove('open');
    if (btn) btn.style.display = '';
}
function kbQuickSubmit(colId) {
    var inp = document.getElementById('kb-qi-' + colId);
    var title = (inp ? inp.value : '').trim();
    if (!title) return;
    post('/kanban/tasks', {title: title, column_id: colId}).then(function(d) {
        if (d.error) { alert(d.error); return; }
        kbQuickClose(colId);
        location.reload();
    });
}

/* ── Done Toggle ───────────────────────────────────────────────── */
function kbToggleDone(taskId) {
    post('/kanban/tasks/' + taskId + '/done', {}).then(function(d) {
        if (!d.ok) { alert(d.error || 'Fehler beim Speichern'); return; }
        var card = document.getElementById('kb-task-' + taskId);
        var btn  = card ? card.querySelector('.kb-done-btn') : null;
        if (card) {
            card.classList.toggle('kb-card--done', d.done);
            card.dataset.done = d.done ? '1' : '0';
        }
        if (btn) {
            btn.classList.toggle('kb-done-btn--on', d.done);
            btn.title = d.done ? 'Als offen markieren' : 'Erledigt';
        }
        // Re-apply filter (onlyOpen might need to hide it now)
        kbFilter();
    });
}

/* ── Task Modal ────────────────────────────────────────────────── */
function kbOpenTaskModal(task) {
    document.getElementById('kb-modal-id').value   = task.id || '';
    document.getElementById('kb-modal-title').value= task.title || '';
    document.getElementById('kb-modal-desc').value = task.description || '';
    document.getElementById('kb-modal-due').value  = task.due_date || '';

    var colSel = document.getElementById('kb-modal-col');
    if (colSel) colSel.value = task.column_id || '';

    var prioSel = document.getElementById('kb-modal-prio');
    if (prioSel) prioSel.value = task.priority || 'normal';

    var catSel = document.getElementById('kb-modal-cat');
    if (catSel) catSel.value = task.category_id || '';

    var assigneeIds = task.assignee_ids || [];
    if (typeof assigneeIds === 'string') assigneeIds = assigneeIds.split(',').filter(Boolean);
    document.querySelectorAll('.kb-modal-assignee').forEach(function(cb){
        cb.checked = assigneeIds.indexOf(String(cb.value)) >= 0 || assigneeIds.indexOf(parseInt(cb.value)) >= 0;
    });

    var isNew = !task.id;
    document.getElementById('kb-modal-title-lbl').textContent = isNew ? 'Neue Aufgabe' : 'Aufgabe bearbeiten';
    var delBtn = document.getElementById('kb-modal-del-btn');
    if (delBtn) delBtn.style.display = isNew ? 'none' : '';

    document.getElementById('kb-task-overlay').classList.add('open');
    document.getElementById('kb-modal-title').focus();
}
function kbCloseTaskModal() {
    document.getElementById('kb-task-overlay').classList.remove('open');
}
function kbSaveTask() {
    var id    = document.getElementById('kb-modal-id').value;
    var title = document.getElementById('kb-modal-title').value.trim();
    if (!title) { document.getElementById('kb-modal-title').focus(); return; }

    var assigneeIds = [];
    document.querySelectorAll('.kb-modal-assignee:checked').forEach(function(cb){ assigneeIds.push(cb.value); });

    var data = {
        title:       title,
        description: document.getElementById('kb-modal-desc').value,
        column_id:   document.getElementById('kb-modal-col').value,
        priority:    document.getElementById('kb-modal-prio').value,
        category_id: document.getElementById('kb-modal-cat').value,
        due_date:    document.getElementById('kb-modal-due').value,
        assignee_ids:assigneeIds
    };

    var url = id ? '/kanban/tasks/' + id : '/kanban/tasks';
    var btn = document.getElementById('kb-modal-save-btn');
    if (btn) btn.disabled = true;

    post(url, data).then(function(d) {
        if (btn) btn.disabled = false;
        if (d.error) { alert(d.error); return; }
        kbCloseTaskModal();
        location.reload();
    });
}
function kbDeleteTask() {
    var id = document.getElementById('kb-modal-id').value;
    if (!id || !confirm('Aufgabe wirklich löschen?')) return;
    post('/kanban/tasks/' + id + '/delete', {}).then(function(d){
        if (d.ok) { kbCloseTaskModal(); location.reload(); }
    });
}

/* ── Column Modal ──────────────────────────────────────────────── */
function kbOpenColModal(col) {
    document.getElementById('kb-col-id').value       = col ? col.id : '';
    document.getElementById('kb-col-name-inp').value = col ? col.name : '';
    document.getElementById('kb-col-wip').value      = col && col.wip_limit ? col.wip_limit : '';
    var color = col ? col.color : '#6c757d';
    document.getElementById('kb-col-color').value = color;
    kbSelectColor(color);

    document.getElementById('kb-col-modal-title').textContent = col ? 'Spalte bearbeiten' : 'Neue Spalte';
    var delBtn = document.getElementById('kb-col-del-btn');
    if (delBtn) delBtn.style.display = col ? '' : 'none';

    document.getElementById('kb-col-overlay').classList.add('open');
    document.getElementById('kb-col-name-inp').focus();
}
function kbCloseColModal() {
    document.getElementById('kb-col-overlay').classList.remove('open');
}
function kbSelectColor(color) {
    document.getElementById('kb-col-color').value = color;
    document.querySelectorAll('.kb-swatch').forEach(function(s){
        s.classList.toggle('active', s.dataset.color === color);
    });
}
function kbSaveColumn() {
    var id    = document.getElementById('kb-col-id').value;
    var name  = document.getElementById('kb-col-name-inp').value.trim();
    var color = document.getElementById('kb-col-color').value;
    var wip   = document.getElementById('kb-col-wip').value;
    if (!name) { document.getElementById('kb-col-name-inp').focus(); return; }

    var url  = id ? '/kanban/columns/' + id : '/kanban/columns';
    var data = {name: name, color: color, wip_limit: wip};

    post(url, data).then(function(d){
        if (d.error) { alert(d.error); return; }
        kbCloseColModal();
        location.reload();
    });
}
function kbDeleteColumn() {
    var id = document.getElementById('kb-col-id').value;
    if (!id || !confirm('Spalte löschen? Aufgaben werden in die erste andere Spalte verschoben.')) return;
    post('/kanban/columns/' + id + '/delete', {}).then(function(d){
        if (d.ok) { kbCloseColModal(); location.reload(); }
    });
}

/* ── Keyboard ──────────────────────────────────────────────────── */
document.addEventListener('keydown', function(e) {
    if (e.key === 'Escape') {
        kbCloseTaskModal();
        kbCloseColModal();
    }
});

/* ── "Neue Aufgabe" per Spalte-Button (falls kein canManage, ignorieren) ── */
<?php if ($can_manage): ?>
document.addEventListener('click', function(e) {
    if (e.target.closest('#kb-col-overlay, #kb-task-overlay')) return;
    var addColBtn = e.target.closest('.kb-add-col');
    if (addColBtn) kbOpenColModal(null);
});
<?php endif; ?>

</script>