Planka: add native Kanban module with proxy API and SPA board view
Deploy HouseHub / deploy (push) Successful in 1s
Deploy HouseHub / deploy (push) Successful in 1s
Adds modules/planka/api.php (PHP proxy to Planka at 10.200.33.10:1337), planka.css, and planka.php (full SPA with board selector, drag-and-drop cards, card modal with checklist). Registers planka in header nav, all 3 lang files, schema_family.sql and enables it for family 1. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
00db9dedfd
commit
7a697213d4
@@ -448,3 +448,14 @@ INSERT IGNORE INTO pf_pv_categories (name, color) VALUES
|
|||||||
('Non classé','#64748b'),('Déco','#ec4899'),('Fonctionnel','#3b82f6'),
|
('Non classé','#64748b'),('Déco','#ec4899'),('Fonctionnel','#3b82f6'),
|
||||||
('Mécanique','#f59e0b'),('Jouets','#10b981'),('Outils','#ef4444'),
|
('Mécanique','#f59e0b'),('Jouets','#10b981'),('Outils','#ef4444'),
|
||||||
('Architecture','#8b5cf6'),('Art','#06b6d4');
|
('Architecture','#8b5cf6'),('Art','#06b6d4');
|
||||||
|
|
||||||
|
-- ─── Planka ───────────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_planka_config (
|
||||||
|
id INT NOT NULL DEFAULT 1,
|
||||||
|
project_id VARCHAR(32),
|
||||||
|
admin_token TEXT,
|
||||||
|
token_expires_at DATETIME,
|
||||||
|
active_board_id VARCHAR(32),
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
|
|||||||
<?php if (in_array('groceries', $mods)): ?><a href="/groceries.php" class="pf-nav-link <?= $activePage === 'groceries' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_groceries') ?></a><?php endif; ?>
|
<?php if (in_array('groceries', $mods)): ?><a href="/groceries.php" class="pf-nav-link <?= $activePage === 'groceries' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_groceries') ?></a><?php endif; ?>
|
||||||
<?php if (in_array('calendar_ios', $mods)): ?><a href="/calendar-ios.php" class="pf-nav-link <?= $activePage === 'calendar-ios' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_calendar_ios') ?></a><?php endif; ?>
|
<?php if (in_array('calendar_ios', $mods)): ?><a href="/calendar-ios.php" class="pf-nav-link <?= $activePage === 'calendar-ios' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_calendar_ios') ?></a><?php endif; ?>
|
||||||
<?php if (in_array('printvault', $mods)): ?><a href="/printvault.php" class="pf-nav-link <?= $activePage === 'printvault' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_printvault') ?></a><?php endif; ?>
|
<?php if (in_array('printvault', $mods)): ?><a href="/printvault.php" class="pf-nav-link <?= $activePage === 'printvault' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_printvault') ?></a><?php endif; ?>
|
||||||
|
<?php if (in_array('planka', $mods)): ?><a href="/planka.php" class="pf-nav-link <?= $activePage === 'planka' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_planka') ?></a><?php endif; ?>
|
||||||
</nav>
|
</nav>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
@@ -108,6 +109,7 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
|
|||||||
<?php if (in_array('groceries',$mods)): ?><a href="/groceries.php" class="pf-mobile-nav-link">🛒 <?= tr('menu_groceries') ?></a><?php endif; ?>
|
<?php if (in_array('groceries',$mods)): ?><a href="/groceries.php" class="pf-mobile-nav-link">🛒 <?= tr('menu_groceries') ?></a><?php endif; ?>
|
||||||
<?php if (in_array('calendar_ios', $mods)): ?><a href="/calendar-ios.php" class="pf-mobile-nav-link">📱 <?= tr('menu_calendar_ios') ?></a><?php endif; ?>
|
<?php if (in_array('calendar_ios', $mods)): ?><a href="/calendar-ios.php" class="pf-mobile-nav-link">📱 <?= tr('menu_calendar_ios') ?></a><?php endif; ?>
|
||||||
<?php if (in_array('printvault', $mods)): ?><a href="/printvault.php" class="pf-mobile-nav-link">🖨️ <?= tr('menu_printvault') ?></a><?php endif; ?>
|
<?php if (in_array('printvault', $mods)): ?><a href="/printvault.php" class="pf-mobile-nav-link">🖨️ <?= tr('menu_printvault') ?></a><?php endif; ?>
|
||||||
|
<?php if (in_array('planka', $mods)): ?><a href="/planka.php" class="pf-mobile-nav-link">📋 <?= tr('menu_planka') ?></a><?php endif; ?>
|
||||||
<a href="/settings.php" class="pf-mobile-nav-link">⚙️ Paramètres</a>
|
<a href="/settings.php" class="pf-mobile-nav-link">⚙️ Paramètres</a>
|
||||||
<?php if (!empty($_SESSION['user']['is_admin'])): ?>
|
<?php if (!empty($_SESSION['user']['is_admin'])): ?>
|
||||||
<a href="/admin/" class="pf-mobile-nav-link" style="color:#2563eb">🛡️ Admin</a>
|
<a href="/admin/" class="pf-mobile-nav-link" style="color:#2563eb">🛡️ Admin</a>
|
||||||
|
|||||||
@@ -152,6 +152,9 @@ return [
|
|||||||
'mod_printvault_desc' => 'Gestioneu els vostres fitxers d\'impressió 3D — STL, 3MF, GCode.',
|
'mod_printvault_desc' => 'Gestioneu els vostres fitxers d\'impressió 3D — STL, 3MF, GCode.',
|
||||||
'menu_printvault' => 'PrintVault',
|
'menu_printvault' => 'PrintVault',
|
||||||
'cta_print' => 'Imprimir',
|
'cta_print' => 'Imprimir',
|
||||||
|
'mod_planka_name' => 'Planka',
|
||||||
|
'mod_planka_desc' => 'Gestioneu els vostres projectes en mode Kanban amb Planka.',
|
||||||
|
'menu_planka' => 'Planka',
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// FAMILY CALENDAR
|
// FAMILY CALENDAR
|
||||||
|
|||||||
@@ -153,6 +153,9 @@ return [
|
|||||||
'mod_printvault_desc' => 'Manage your 3D print files — STL, 3MF, GCode with interactive viewer.',
|
'mod_printvault_desc' => 'Manage your 3D print files — STL, 3MF, GCode with interactive viewer.',
|
||||||
'menu_printvault' => 'PrintVault',
|
'menu_printvault' => 'PrintVault',
|
||||||
'cta_print' => 'Print',
|
'cta_print' => 'Print',
|
||||||
|
'mod_planka_name' => 'Planka',
|
||||||
|
'mod_planka_desc' => 'Manage your projects in Kanban mode with Planka.',
|
||||||
|
'menu_planka' => 'Planka',
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// FAMILY CALENDAR
|
// FAMILY CALENDAR
|
||||||
|
|||||||
@@ -155,6 +155,9 @@ return [
|
|||||||
'mod_printvault_desc' => 'Gérez vos fichiers d\'impression 3D — STL, 3MF, GCode avec viewer interactif.',
|
'mod_printvault_desc' => 'Gérez vos fichiers d\'impression 3D — STL, 3MF, GCode avec viewer interactif.',
|
||||||
'menu_printvault' => 'PrintVault',
|
'menu_printvault' => 'PrintVault',
|
||||||
'cta_print' => 'Imprimer',
|
'cta_print' => 'Imprimer',
|
||||||
|
'mod_planka_name' => 'Planka',
|
||||||
|
'mod_planka_desc' => 'Gérez vos projets en mode Kanban avec Planka.',
|
||||||
|
'menu_planka' => 'Planka',
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// FAMILY CALENDAR
|
// FAMILY CALENDAR
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<?php
|
||||||
|
ob_start();
|
||||||
|
require_once dirname(__DIR__, 2) . '/includes/auth.php';
|
||||||
|
require_login();
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
|
||||||
|
|
||||||
|
set_exception_handler(function(\Throwable $e) {
|
||||||
|
if (!headers_sent()) { header('Content-Type: application/json'); http_response_code(500); }
|
||||||
|
echo json_encode(['ok' => false, 'error' => $e->getMessage()]); exit;
|
||||||
|
});
|
||||||
|
|
||||||
|
require_once dirname(__DIR__, 2) . '/includes/db.php';
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
|
||||||
|
function tOk($d) { ob_end_clean(); echo json_encode(['ok' => true, 'data' => $d], JSON_UNESCAPED_UNICODE); exit; }
|
||||||
|
function tErr($m, $c = 400) { ob_end_clean(); http_response_code($c); echo json_encode(['ok' => false, 'error' => $m]); exit; }
|
||||||
|
function tBody() { return json_decode(file_get_contents('php://input'), true) ?? []; }
|
||||||
|
|
||||||
|
function planka_api($method, $path, $body = null, $token = null) {
|
||||||
|
$url = 'http://10.200.33.10:1337' . $path;
|
||||||
|
$headers = ['Content-Type: application/json'];
|
||||||
|
if ($token) $headers[] = 'Authorization: Bearer ' . $token;
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_CUSTOMREQUEST => $method,
|
||||||
|
CURLOPT_HTTPHEADER => $headers,
|
||||||
|
CURLOPT_TIMEOUT => 10,
|
||||||
|
]);
|
||||||
|
if ($body) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
|
||||||
|
$result = curl_exec($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
return json_decode($result, true) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function planka_token(PDO $pdo) {
|
||||||
|
$row = $pdo->query("SELECT admin_token, token_expires_at FROM pf_planka_config WHERE id=1")->fetch(PDO::FETCH_ASSOC);
|
||||||
|
if ($row && $row['token_expires_at'] > date('Y-m-d H:i:s')) return $row['admin_token'];
|
||||||
|
$resp = planka_api('POST', '/api/access-tokens', ['emailOrUsername' => 'admin', 'password' => 'ideine']);
|
||||||
|
$token = $resp['item'] ?? null;
|
||||||
|
if (!$token) return null;
|
||||||
|
$exp = date('Y-m-d H:i:s', strtotime('+30 days'));
|
||||||
|
$pdo->prepare("INSERT INTO pf_planka_config (id,admin_token,token_expires_at) VALUES (1,?,?) ON DUPLICATE KEY UPDATE admin_token=VALUES(admin_token),token_expires_at=VALUES(token_expires_at)")->execute([$token, $exp]);
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = planka_token($pdo);
|
||||||
|
if (!$token) tErr('Impossible d\'obtenir un token Planka', 503);
|
||||||
|
|
||||||
|
if ($action === 'boards') {
|
||||||
|
$cfg = $pdo->query("SELECT project_id FROM pf_planka_config WHERE id=1")->fetch(PDO::FETCH_ASSOC);
|
||||||
|
$project_id = $cfg['project_id'] ?? '1713338747178714130';
|
||||||
|
$resp = planka_api('GET', '/api/projects/' . $project_id, null, $token);
|
||||||
|
$boards = [];
|
||||||
|
if (isset($resp['included']['boards'])) {
|
||||||
|
foreach ($resp['included']['boards'] as $b) {
|
||||||
|
$boards[] = ['id' => $b['id'], 'name' => $b['name']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tOk($boards);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'board') {
|
||||||
|
$id = $_GET['id'] ?? null; if (!$id) tErr('ID manquant');
|
||||||
|
$resp = planka_api('GET', '/api/boards/' . $id, null, $token);
|
||||||
|
$inc = $resp['included'] ?? [];
|
||||||
|
tOk([
|
||||||
|
'lists' => $inc['lists'] ?? [],
|
||||||
|
'cards' => $inc['cards'] ?? [],
|
||||||
|
'labels' => $inc['labels'] ?? [],
|
||||||
|
'members' => $inc['users'] ?? [],
|
||||||
|
'cardLabels' => $inc['cardLabels'] ?? [],
|
||||||
|
'tasks' => $inc['tasks'] ?? [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'set_active_board') {
|
||||||
|
$id = $_GET['id'] ?? null; if (!$id) tErr('ID manquant');
|
||||||
|
$pdo->prepare("INSERT INTO pf_planka_config (id, active_board_id) VALUES (1,?) ON DUPLICATE KEY UPDATE active_board_id=VALUES(active_board_id)")->execute([$id]);
|
||||||
|
tOk(['updated' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'create_card') {
|
||||||
|
$list_id = $_GET['list_id'] ?? null; if (!$list_id) tErr('list_id manquant');
|
||||||
|
$board_id = $_GET['board_id'] ?? null; if (!$board_id) tErr('board_id manquant');
|
||||||
|
$name = trim($_GET['name'] ?? ''); if (!$name) tErr('name manquant');
|
||||||
|
$resp = planka_api('POST', '/api/boards/' . $board_id . '/cards', [
|
||||||
|
'listId' => $list_id,
|
||||||
|
'name' => $name,
|
||||||
|
'position' => 65535,
|
||||||
|
], $token);
|
||||||
|
tOk($resp['item'] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'update_card') {
|
||||||
|
$id = $_GET['id'] ?? null; if (!$id) tErr('ID manquant');
|
||||||
|
$d = tBody();
|
||||||
|
$payload = [];
|
||||||
|
if (isset($d['name'])) $payload['name'] = $d['name'];
|
||||||
|
if (isset($d['description'])) $payload['description'] = $d['description'];
|
||||||
|
if (array_key_exists('dueDate', $d)) $payload['dueDate'] = $d['dueDate'];
|
||||||
|
if (isset($d['listId'])) $payload['listId'] = $d['listId'];
|
||||||
|
$resp = planka_api('PATCH', '/api/cards/' . $id, $payload, $token);
|
||||||
|
tOk($resp['item'] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'delete_card') {
|
||||||
|
$id = $_GET['id'] ?? null; if (!$id) tErr('ID manquant');
|
||||||
|
planka_api('DELETE', '/api/cards/' . $id, null, $token);
|
||||||
|
tOk(['deleted' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'create_task') {
|
||||||
|
$card_id = $_GET['card_id'] ?? null; if (!$card_id) tErr('card_id manquant');
|
||||||
|
$name = trim($_GET['name'] ?? ''); if (!$name) tErr('name manquant');
|
||||||
|
$resp = planka_api('POST', '/api/cards/' . $card_id . '/tasks', [
|
||||||
|
'name' => $name,
|
||||||
|
'position' => 65535,
|
||||||
|
], $token);
|
||||||
|
tOk($resp['item'] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'toggle_task') {
|
||||||
|
$id = $_GET['id'] ?? null; if (!$id) tErr('ID manquant');
|
||||||
|
$is_completed = ($_GET['is_completed'] ?? '0') === '1';
|
||||||
|
$resp = planka_api('PATCH', '/api/tasks/' . $id, ['isCompleted' => $is_completed], $token);
|
||||||
|
tOk($resp['item'] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'set_project') {
|
||||||
|
$project_id = $_GET['project_id'] ?? null; if (!$project_id) tErr('project_id manquant');
|
||||||
|
$pdo->prepare("INSERT INTO pf_planka_config (id, project_id) VALUES (1,?) ON DUPLICATE KEY UPDATE project_id=VALUES(project_id)")->execute([$project_id]);
|
||||||
|
tOk(['updated' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'config') {
|
||||||
|
$row = $pdo->query("SELECT project_id, active_board_id FROM pf_planka_config WHERE id=1")->fetch(PDO::FETCH_ASSOC);
|
||||||
|
tOk($row ?: ['project_id' => '1713338747178714130', 'active_board_id' => null]);
|
||||||
|
}
|
||||||
|
|
||||||
|
tErr('Action inconnue', 404);
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
.pk-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: calc(100vh - 64px);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .75rem;
|
||||||
|
padding: .65rem 1.25rem;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-topbar h1 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-main);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-board-selector {
|
||||||
|
display: flex;
|
||||||
|
gap: .4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-board-btn {
|
||||||
|
padding: .3rem .75rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
background: var(--bg-page);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: .82rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-board-btn:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-board-btn.active {
|
||||||
|
background: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-topbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-btn-icon {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: .3rem .5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: .9rem;
|
||||||
|
transition: all .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-btn-icon:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-board-area {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
padding: 1rem;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-board-area::-webkit-scrollbar { height: 8px; }
|
||||||
|
.pk-board-area::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
.pk-board-area::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 4px; }
|
||||||
|
|
||||||
|
.pk-list {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 272px;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-list-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: .65rem .85rem;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-list-name {
|
||||||
|
font-size: .85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-list-count {
|
||||||
|
font-size: .72rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--bg-page);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: .1rem .45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-cards {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: .5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .4rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-cards::-webkit-scrollbar { width: 4px; }
|
||||||
|
.pk-cards::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 2px; }
|
||||||
|
|
||||||
|
.pk-card {
|
||||||
|
background: var(--bg-page);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: .6rem .75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all .15s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-card:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 2px 8px rgba(59,130,246,.12);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-card.is-dragging {
|
||||||
|
opacity: .5;
|
||||||
|
transform: rotate(2deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-card-labels {
|
||||||
|
display: flex;
|
||||||
|
gap: .25rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: .35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-label {
|
||||||
|
height: 6px;
|
||||||
|
width: 36px;
|
||||||
|
border-radius: 3px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-card-name {
|
||||||
|
font-size: .83rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-main);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-card-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
margin-top: .4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-due {
|
||||||
|
font-size: .72rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-due.overdue { color: #ef4444; }
|
||||||
|
.pk-due.soon { color: #f97316; }
|
||||||
|
|
||||||
|
.pk-task-count {
|
||||||
|
font-size: .72rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: .05rem .4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-add-card-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .4rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: .5rem .85rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
border-radius: 0 0 12px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: .82rem;
|
||||||
|
text-align: left;
|
||||||
|
transition: background .15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-add-card-btn:hover {
|
||||||
|
background: var(--bg-page);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-drop-zone {
|
||||||
|
min-height: 4px;
|
||||||
|
transition: min-height .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-drop-zone.drag-over {
|
||||||
|
min-height: 48px;
|
||||||
|
border: 2px dashed var(--primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(59,130,246,.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: .9rem;
|
||||||
|
gap: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-spinner {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 3px solid var(--border-light);
|
||||||
|
border-top-color: var(--primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pkSpin .7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pkSpin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.pk-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,.45);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-modal-backdrop.hidden { display: none; }
|
||||||
|
|
||||||
|
.pk-modal {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 14px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-modal-header h3 { margin: 0; font-size: .95rem; color: var(--text-main); }
|
||||||
|
|
||||||
|
.pk-modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0 .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-modal-body {
|
||||||
|
padding: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-modal-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: .5rem;
|
||||||
|
padding: .85rem 1.25rem;
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-form-group { display: flex; flex-direction: column; gap: .3rem; }
|
||||||
|
.pk-form-label { font-size: .75rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: .04em; }
|
||||||
|
|
||||||
|
.pk-input, .pk-textarea, .pk-select {
|
||||||
|
padding: .5rem .7rem;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-page);
|
||||||
|
color: var(--text-main);
|
||||||
|
font-size: .875rem;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color .15s;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-input:focus, .pk-textarea:focus, .pk-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(59,130,246,.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-textarea { resize: vertical; min-height: 80px; }
|
||||||
|
|
||||||
|
.pk-checklist { display: flex; flex-direction: column; gap: .35rem; }
|
||||||
|
|
||||||
|
.pk-task-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .55rem;
|
||||||
|
padding: .3rem .5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background .1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-task-item:hover { background: var(--bg-page); }
|
||||||
|
|
||||||
|
.pk-task-cb {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-task-label {
|
||||||
|
font-size: .83rem;
|
||||||
|
color: var(--text-main);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-task-label.done {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-add-task-row {
|
||||||
|
display: flex;
|
||||||
|
gap: .4rem;
|
||||||
|
margin-top: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-add-task-row .pk-input { flex: 1; }
|
||||||
|
|
||||||
|
.pk-btn {
|
||||||
|
padding: .45rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: .83rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-btn-primary {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-btn-primary:hover { filter: brightness(1.1); }
|
||||||
|
|
||||||
|
.pk-btn-secondary {
|
||||||
|
background: var(--bg-page);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-btn-secondary:hover { border-color: var(--primary); color: var(--primary); }
|
||||||
|
|
||||||
|
.pk-btn-danger {
|
||||||
|
background: #ef4444;
|
||||||
|
color: #fff;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-btn-danger:hover { filter: brightness(1.1); }
|
||||||
|
|
||||||
|
.pk-btn-sm {
|
||||||
|
padding: .3rem .65rem;
|
||||||
|
font-size: .78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-label-color--berry-red { background: #ef4444; }
|
||||||
|
.pk-label-color--fresh-salad { background: #22c55e; }
|
||||||
|
.pk-label-color--pumpkin-orange { background: #f97316; }
|
||||||
|
.pk-label-color--morning-sky { background: #3b82f6; }
|
||||||
|
.pk-label-color--pink-tulip { background: #ec4899; }
|
||||||
|
.pk-label-color--midnight-blue { background: #1e40af; }
|
||||||
|
.pk-label-color--lagoon-blue { background: #0ea5e9; }
|
||||||
|
.pk-label-color--purpley { background: #a855f7; }
|
||||||
|
.pk-label-color--egg-white { background: #fef3c7; }
|
||||||
|
.pk-label-color--anti-flash-white { background: #f1f5f9; }
|
||||||
|
|
||||||
|
[data-theme="dark"] .pk-modal { background: var(--bg-panel); }
|
||||||
|
[data-theme="dark"] .pk-list { background: var(--bg-panel); }
|
||||||
|
[data-theme="dark"] .pk-card { background: var(--bg-page); }
|
||||||
|
[data-theme="dark"] .pk-input,
|
||||||
|
[data-theme="dark"] .pk-textarea,
|
||||||
|
[data-theme="dark"] .pk-select { background: var(--bg-page); color: var(--text-main); }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.pk-list { width: 240px; }
|
||||||
|
.pk-page { height: auto; overflow: visible; }
|
||||||
|
.pk-board-area { overflow-x: auto; height: auto; padding-bottom: 4rem; }
|
||||||
|
}
|
||||||
+333
@@ -0,0 +1,333 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/includes/auth.php';
|
||||||
|
require_login();
|
||||||
|
require_once __DIR__ . '/includes/i18n.php';
|
||||||
|
|
||||||
|
$pageTitle = 'Planka — HouseHub';
|
||||||
|
$activePage = 'planka';
|
||||||
|
$pageCss = '/modules/planka/assets/planka.css';
|
||||||
|
require __DIR__ . '/header.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="pk-page" id="pk-page">
|
||||||
|
|
||||||
|
<div class="pk-topbar">
|
||||||
|
<h1>📋 Planka</h1>
|
||||||
|
<div class="pk-board-selector" id="pk-board-selector"></div>
|
||||||
|
<div class="pk-topbar-actions">
|
||||||
|
<button class="pk-btn-icon" onclick="openSettings()" title="Paramètres">⚙️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pk-board-area" id="pk-board-area">
|
||||||
|
<div class="pk-loading" id="pk-loading">
|
||||||
|
<div class="pk-spinner"></div>
|
||||||
|
Chargement…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── MODAL CARTE ──────────────────────────────────────────────────────────── -->
|
||||||
|
<div class="pk-modal-backdrop hidden" id="pk-card-modal">
|
||||||
|
<div class="pk-modal">
|
||||||
|
<div class="pk-modal-header">
|
||||||
|
<h3>Carte</h3>
|
||||||
|
<button class="pk-modal-close" onclick="closeCardModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="pk-modal-body">
|
||||||
|
<div class="pk-form-group">
|
||||||
|
<label class="pk-form-label">Titre</label>
|
||||||
|
<input type="text" id="pk-card-name" class="pk-input" placeholder="Titre de la carte">
|
||||||
|
</div>
|
||||||
|
<div class="pk-form-group">
|
||||||
|
<label class="pk-form-label">Description</label>
|
||||||
|
<textarea id="pk-card-desc" class="pk-textarea" placeholder="Description…"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="pk-form-group">
|
||||||
|
<label class="pk-form-label">Date d'échéance</label>
|
||||||
|
<input type="date" id="pk-card-due" class="pk-input">
|
||||||
|
</div>
|
||||||
|
<div class="pk-form-group">
|
||||||
|
<label class="pk-form-label">Colonne</label>
|
||||||
|
<select id="pk-card-list" class="pk-select"></select>
|
||||||
|
</div>
|
||||||
|
<div class="pk-form-group">
|
||||||
|
<label class="pk-form-label">Checklist</label>
|
||||||
|
<div class="pk-checklist" id="pk-task-list"></div>
|
||||||
|
<div class="pk-add-task-row">
|
||||||
|
<input type="text" id="pk-new-task-name" class="pk-input" placeholder="Nouvelle tâche…">
|
||||||
|
<button class="pk-btn pk-btn-secondary pk-btn-sm" onclick="addTask()">+ Ajouter</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pk-modal-footer">
|
||||||
|
<button class="pk-btn pk-btn-danger" id="pk-delete-btn" onclick="deleteCard()">Supprimer</button>
|
||||||
|
<button class="pk-btn pk-btn-secondary" onclick="closeCardModal()">Annuler</button>
|
||||||
|
<button class="pk-btn pk-btn-primary" onclick="saveCard()">Enregistrer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── MODAL PARAMÈTRES ─────────────────────────────────────────────────────── -->
|
||||||
|
<div class="pk-modal-backdrop hidden" id="pk-settings-modal">
|
||||||
|
<div class="pk-modal">
|
||||||
|
<div class="pk-modal-header">
|
||||||
|
<h3>⚙️ Paramètres Planka</h3>
|
||||||
|
<button class="pk-modal-close" onclick="closeSettings()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="pk-modal-body">
|
||||||
|
<div class="pk-form-group">
|
||||||
|
<label class="pk-form-label">Project ID</label>
|
||||||
|
<input type="text" id="pk-project-id" class="pk-input" value="1713338747178714130">
|
||||||
|
<span style="font-size:.72rem;color:var(--text-muted);margin-top:.2rem">ID du projet Planka à afficher.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pk-modal-footer">
|
||||||
|
<button class="pk-btn pk-btn-secondary" onclick="closeSettings()">Annuler</button>
|
||||||
|
<button class="pk-btn pk-btn-primary" onclick="saveSettings()">Enregistrer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API = 'modules/planka/api.php';
|
||||||
|
let state = { boards: [], activeBoardId: null, lists: [], cards: [], labels: [], cardLabels: [], tasks: [] };
|
||||||
|
let editingCard = null;
|
||||||
|
let dragCardId = null;
|
||||||
|
|
||||||
|
async function apiFetch(action, params = {}, method = 'GET', body = null) {
|
||||||
|
const qs = new URLSearchParams({ action, ...params }).toString();
|
||||||
|
const opts = { method, credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' } };
|
||||||
|
if (body) opts.body = JSON.stringify(body);
|
||||||
|
const r = await fetch(`${API}?${qs}`, opts);
|
||||||
|
const txt = await r.text();
|
||||||
|
try { return JSON.parse(txt); } catch { return { ok: false, error: txt }; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
const cfg = await apiFetch('config');
|
||||||
|
if (cfg.ok) {
|
||||||
|
if (cfg.data.active_board_id) state.activeBoardId = cfg.data.active_board_id;
|
||||||
|
}
|
||||||
|
const res = await apiFetch('boards');
|
||||||
|
if (!res.ok) { document.getElementById('pk-loading').textContent = 'Erreur: ' + (res.error || 'impossible de charger les boards'); return; }
|
||||||
|
state.boards = res.data;
|
||||||
|
if (!state.activeBoardId && state.boards.length) state.activeBoardId = state.boards[0].id;
|
||||||
|
renderBoardSelector();
|
||||||
|
if (state.activeBoardId) await loadBoard(state.activeBoardId);
|
||||||
|
else document.getElementById('pk-loading').textContent = 'Aucun board disponible.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBoardSelector() {
|
||||||
|
const el = document.getElementById('pk-board-selector');
|
||||||
|
el.innerHTML = state.boards.map(b => `<button class="pk-board-btn${b.id === state.activeBoardId ? ' active' : ''}" onclick="switchBoard('${b.id}')">${b.name}</button>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchBoard(id) {
|
||||||
|
state.activeBoardId = id;
|
||||||
|
renderBoardSelector();
|
||||||
|
await apiFetch('set_active_board', { id });
|
||||||
|
await loadBoard(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBoard(id) {
|
||||||
|
document.getElementById('pk-board-area').innerHTML = '<div class="pk-loading"><div class="pk-spinner"></div>Chargement…</div>';
|
||||||
|
const res = await apiFetch('board', { id });
|
||||||
|
if (!res.ok) { document.getElementById('pk-board-area').textContent = 'Erreur: ' + res.error; return; }
|
||||||
|
const d = res.data;
|
||||||
|
state.lists = d.lists || [];
|
||||||
|
state.cards = d.cards || [];
|
||||||
|
state.labels = d.labels || [];
|
||||||
|
state.cardLabels = d.cardLabels || [];
|
||||||
|
state.tasks = d.tasks || [];
|
||||||
|
renderBoard();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBoard() {
|
||||||
|
const area = document.getElementById('pk-board-area');
|
||||||
|
if (!state.lists.length) { area.innerHTML = '<div class="pk-loading">Aucune liste dans ce board.</div>'; return; }
|
||||||
|
const sorted = [...state.lists].sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
|
||||||
|
area.innerHTML = sorted.map(list => renderList(list)).join('');
|
||||||
|
area.querySelectorAll('.pk-card').forEach(el => {
|
||||||
|
el.addEventListener('click', () => openCardModal(el.dataset.id));
|
||||||
|
el.addEventListener('dragstart', e => { dragCardId = el.dataset.id; el.classList.add('is-dragging'); });
|
||||||
|
el.addEventListener('dragend', e => { el.classList.remove('is-dragging'); dragCardId = null; });
|
||||||
|
});
|
||||||
|
area.querySelectorAll('.pk-drop-zone').forEach(el => {
|
||||||
|
el.addEventListener('dragover', e => { e.preventDefault(); el.classList.add('drag-over'); });
|
||||||
|
el.addEventListener('dragleave', () => el.classList.remove('drag-over'));
|
||||||
|
el.addEventListener('drop', e => { e.preventDefault(); el.classList.remove('drag-over'); dropCard(el.dataset.listId); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList(list) {
|
||||||
|
const cards = state.cards.filter(c => c.listId === list.id).sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
|
||||||
|
return `
|
||||||
|
<div class="pk-list" data-list-id="${list.id}">
|
||||||
|
<div class="pk-list-header">
|
||||||
|
<span class="pk-list-name">${esc(list.name)}</span>
|
||||||
|
<span class="pk-list-count">${cards.length}</span>
|
||||||
|
</div>
|
||||||
|
<div class="pk-cards" id="cards-${list.id}">
|
||||||
|
<div class="pk-drop-zone" data-list-id="${list.id}"></div>
|
||||||
|
${cards.map(c => renderCard(c)).join('')}
|
||||||
|
</div>
|
||||||
|
<button class="pk-add-card-btn" onclick="quickAddCard('${list.id}')">+ Ajouter une carte</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCard(card) {
|
||||||
|
const labels = (state.cardLabels || []).filter(cl => cl.cardId === card.id).map(cl => {
|
||||||
|
const lbl = (state.labels || []).find(l => l.id === cl.labelId);
|
||||||
|
if (!lbl) return '';
|
||||||
|
return `<span class="pk-label pk-label-color--${lbl.color || 'morning-sky'}" title="${esc(lbl.name || '')}"></span>`;
|
||||||
|
}).join('');
|
||||||
|
const cardTasks = (state.tasks || []).filter(t => t.cardId === card.id);
|
||||||
|
const doneCount = cardTasks.filter(t => t.isCompleted).length;
|
||||||
|
const dueHtml = card.dueDate ? (() => {
|
||||||
|
const d = new Date(card.dueDate); const now = new Date();
|
||||||
|
const diff = (d - now) / 86400000;
|
||||||
|
const cls = diff < 0 ? 'overdue' : diff < 3 ? 'soon' : '';
|
||||||
|
return `<span class="pk-due ${cls}">📅 ${formatDate(card.dueDate)}</span>`;
|
||||||
|
})() : '';
|
||||||
|
const taskHtml = cardTasks.length ? `<span class="pk-task-count">☑ ${doneCount}/${cardTasks.length}</span>` : '';
|
||||||
|
return `
|
||||||
|
<div class="pk-card" data-id="${card.id}" draggable="true">
|
||||||
|
${labels ? `<div class="pk-card-labels">${labels}</div>` : ''}
|
||||||
|
<div class="pk-card-name">${esc(card.name)}</div>
|
||||||
|
${dueHtml || taskHtml ? `<div class="pk-card-meta">${dueHtml}${taskHtml}</div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dropCard(listId) {
|
||||||
|
if (!dragCardId) return;
|
||||||
|
const card = state.cards.find(c => c.id === dragCardId);
|
||||||
|
if (!card || card.listId === listId) return;
|
||||||
|
await apiFetch('update_card', { id: dragCardId }, 'PATCH', { listId });
|
||||||
|
card.listId = listId;
|
||||||
|
renderBoard();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function quickAddCard(listId) {
|
||||||
|
const name = prompt('Nom de la carte :');
|
||||||
|
if (!name || !name.trim()) return;
|
||||||
|
const res = await apiFetch('create_card', { list_id: listId, board_id: state.activeBoardId, name: name.trim() });
|
||||||
|
if (!res.ok) { showToast('Erreur: ' + res.error, 'error'); return; }
|
||||||
|
state.cards.push(res.data);
|
||||||
|
renderBoard();
|
||||||
|
showToast('Carte créée');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCardModal(id) {
|
||||||
|
const card = state.cards.find(c => c.id === id);
|
||||||
|
if (!card) return;
|
||||||
|
editingCard = card;
|
||||||
|
document.getElementById('pk-card-name').value = card.name || '';
|
||||||
|
document.getElementById('pk-card-desc').value = card.description || '';
|
||||||
|
document.getElementById('pk-card-due').value = card.dueDate ? card.dueDate.substring(0, 10) : '';
|
||||||
|
const sel = document.getElementById('pk-card-list');
|
||||||
|
sel.innerHTML = state.lists.map(l => `<option value="${l.id}"${l.id === card.listId ? ' selected' : ''}>${esc(l.name)}</option>`).join('');
|
||||||
|
renderTaskList(card);
|
||||||
|
document.getElementById('pk-card-modal').classList.remove('hidden');
|
||||||
|
document.getElementById('pk-new-task-name').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTaskList(card) {
|
||||||
|
const el = document.getElementById('pk-task-list');
|
||||||
|
const tasks = (state.tasks || []).filter(t => t.cardId === card.id).sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
|
||||||
|
el.innerHTML = tasks.map(t => `
|
||||||
|
<div class="pk-task-item" data-task-id="${t.id}">
|
||||||
|
<input type="checkbox" class="pk-task-cb" ${t.isCompleted ? 'checked' : ''} onchange="toggleTask('${t.id}', this.checked)">
|
||||||
|
<span class="pk-task-label${t.isCompleted ? ' done' : ''}">${esc(t.name)}</span>
|
||||||
|
</div>`).join('') || '<span style="font-size:.78rem;color:var(--text-muted)">Aucune tâche</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCardModal() {
|
||||||
|
document.getElementById('pk-card-modal').classList.add('hidden');
|
||||||
|
editingCard = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCard() {
|
||||||
|
if (!editingCard) return;
|
||||||
|
const name = document.getElementById('pk-card-name').value.trim();
|
||||||
|
if (!name) { showToast('Titre requis', 'error'); return; }
|
||||||
|
const due = document.getElementById('pk-card-due').value;
|
||||||
|
const body = {
|
||||||
|
name,
|
||||||
|
description: document.getElementById('pk-card-desc').value,
|
||||||
|
dueDate: due ? due + 'T00:00:00.000Z' : null,
|
||||||
|
listId: document.getElementById('pk-card-list').value,
|
||||||
|
};
|
||||||
|
const res = await apiFetch('update_card', { id: editingCard.id }, 'PATCH', body);
|
||||||
|
if (!res.ok) { showToast('Erreur: ' + res.error, 'error'); return; }
|
||||||
|
const idx = state.cards.findIndex(c => c.id === editingCard.id);
|
||||||
|
if (idx >= 0) state.cards[idx] = { ...state.cards[idx], ...res.data };
|
||||||
|
closeCardModal();
|
||||||
|
renderBoard();
|
||||||
|
showToast('Carte enregistrée');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCard() {
|
||||||
|
if (!editingCard) return;
|
||||||
|
const ok = await pachaConfirm('Supprimer la carte ?', 'Cette action est irréversible.');
|
||||||
|
if (!ok) return;
|
||||||
|
const res = await apiFetch('delete_card', { id: editingCard.id });
|
||||||
|
if (!res.ok) { showToast('Erreur: ' + res.error, 'error'); return; }
|
||||||
|
state.cards = state.cards.filter(c => c.id !== editingCard.id);
|
||||||
|
closeCardModal();
|
||||||
|
renderBoard();
|
||||||
|
showToast('Carte supprimée');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addTask() {
|
||||||
|
if (!editingCard) return;
|
||||||
|
const name = document.getElementById('pk-new-task-name').value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
const res = await apiFetch('create_task', { card_id: editingCard.id, name });
|
||||||
|
if (!res.ok) { showToast('Erreur: ' + res.error, 'error'); return; }
|
||||||
|
state.tasks.push(res.data);
|
||||||
|
document.getElementById('pk-new-task-name').value = '';
|
||||||
|
renderTaskList(editingCard);
|
||||||
|
renderBoard();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleTask(id, checked) {
|
||||||
|
const res = await apiFetch('toggle_task', { id, is_completed: checked ? '1' : '0' });
|
||||||
|
if (!res.ok) { showToast('Erreur: ' + res.error, 'error'); return; }
|
||||||
|
const t = state.tasks.find(t => t.id === id);
|
||||||
|
if (t) { t.isCompleted = checked; renderTaskList(editingCard); renderBoard(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSettings() {
|
||||||
|
apiFetch('config').then(res => {
|
||||||
|
if (res.ok && res.data.project_id) document.getElementById('pk-project-id').value = res.data.project_id;
|
||||||
|
});
|
||||||
|
document.getElementById('pk-settings-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSettings() { document.getElementById('pk-settings-modal').classList.add('hidden'); }
|
||||||
|
|
||||||
|
async function saveSettings() {
|
||||||
|
const project_id = document.getElementById('pk-project-id').value.trim();
|
||||||
|
if (!project_id) return;
|
||||||
|
const res = await apiFetch('set_project', { project_id });
|
||||||
|
if (!res.ok) { showToast('Erreur: ' + res.error, 'error'); return; }
|
||||||
|
closeSettings();
|
||||||
|
state.activeBoardId = null;
|
||||||
|
showToast('Projet mis à jour, rechargement…');
|
||||||
|
setTimeout(() => init(), 800);
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) { const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
|
||||||
|
|
||||||
|
function formatDate(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' });
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<?php require __DIR__ . '/footer.php'; ?>
|
||||||
Reference in New Issue
Block a user