Files
HouseHub/planka.php
percoandClaude Sonnet 4.6 969bd5c2bf
Deploy HouseHub / deploy (push) Successful in 1s
fix(planka): tri intra-liste par drop sur moitié haute/basse de carte
- Chaque carte est une zone de drop: moitié haute = avant, moitié basse = après
- Indicateur visuel: bordure bleue top ou bottom selon la position du curseur
- Suppression des pk-drop-before (zones 4px invisibles remplacées)
- Colonne comme zone de secours pour listes vides

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 18:15:07 +02:00

417 lines
19 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
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');
const sorted = [...state.lists].sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
area.innerHTML = sorted.map(list => renderList(list)).join('') + `
<div class="pk-list pk-list-new">
<button class="pk-add-list-btn" onclick="promptCreateList()"> Ajouter une liste</button>
</div>`;
// ── Drag source ──────────────────────────────────────────────────────────
area.querySelectorAll('.pk-card').forEach(el => {
el.addEventListener('click', () => openCardModal(el.dataset.id));
el.addEventListener('dragstart', e => {
e.dataTransfer.setData('card-id', el.dataset.id);
e.dataTransfer.effectAllowed = 'move';
setTimeout(() => el.classList.add('is-dragging'), 0);
});
el.addEventListener('dragend', () => {
el.classList.remove('is-dragging');
area.querySelectorAll('.drop-top,.drop-bottom,.drag-over').forEach(x =>
x.classList.remove('drop-top','drop-bottom','drag-over')
);
});
// ── Drop ON a card: top half = before, bottom half = after ──────────
el.addEventListener('dragover', e => {
e.preventDefault(); e.stopPropagation();
e.dataTransfer.dropEffect = 'move';
const mid = el.getBoundingClientRect().top + el.getBoundingClientRect().height / 2;
if (e.clientY < mid) { el.classList.add('drop-top'); el.classList.remove('drop-bottom'); }
else { el.classList.add('drop-bottom'); el.classList.remove('drop-top'); }
});
el.addEventListener('dragleave', () => el.classList.remove('drop-top','drop-bottom'));
el.addEventListener('drop', e => {
e.preventDefault(); e.stopPropagation();
const isBefore = el.classList.contains('drop-top');
el.classList.remove('drop-top','drop-bottom');
const cardId = e.dataTransfer.getData('card-id');
if (!cardId || cardId === el.dataset.id) return;
const listId = el.dataset.listId;
const sorted = state.cards.filter(c => c.listId === listId && c.id !== cardId)
.sort((a, b) => (a.position||0) - (b.position||0));
const idx = sorted.findIndex(c => c.id === el.dataset.id);
let pos;
if (isBefore) {
pos = idx === 0
? (sorted[0].position || 65535) / 2
: ((sorted[idx-1].position||0) + (sorted[idx].position||0)) / 2;
} else {
pos = idx === sorted.length - 1
? (sorted[idx].position || 0) + 65535
: ((sorted[idx].position||0) + (sorted[idx+1].position||0)) / 2;
}
moveCard(cardId, listId, pos);
});
});
// ── Drop at end of column (empty list or after all cards) ───────────────
area.querySelectorAll('.pk-cards').forEach(el => {
el.addEventListener('dragover', e => {
// Only highlight column when not over a card
if (!e.target.closest('.pk-card')) { e.preventDefault(); el.classList.add('drag-over'); }
});
el.addEventListener('dragleave', e => { if (!el.contains(e.relatedTarget)) el.classList.remove('drag-over'); });
el.addEventListener('drop', e => {
if (e.target.closest('.pk-card')) return; // handled by card listener
e.preventDefault();
el.classList.remove('drag-over');
const cardId = e.dataTransfer.getData('card-id');
if (!cardId) return;
const listId = el.dataset.listId;
const others = state.cards.filter(c => c.listId === listId && c.id !== cardId);
const maxPos = others.reduce((m, c) => Math.max(m, c.position||0), 0);
moveCard(cardId, listId, maxPos + 65535);
});
});
}
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}" data-list-id="${list.id}">
${cards.length === 0 ? '<div class="pk-drop-hint">Déposer ici</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'}">${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}" data-list-id="${card.listId}" data-position="${card.position ?? 0}" 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 moveCard(cardId, listId, position) {
const card = state.cards.find(c => c.id === cardId);
if (!card) return;
// Skip no-op (same list, same position range)
if (card.listId === listId && Math.abs((card.position || 0) - position) < 1) return;
const body = { listId, position };
const res = await apiFetch('update_card', { id: cardId }, 'PATCH', body);
if (!res.ok) { showToast('Erreur : ' + (res.error || 'déplacement impossible'), 'error'); return; }
card.listId = listId;
card.position = position;
renderBoard();
showToast('Carte déplacée ✓');
}
async function quickAddCard(listId) {
const name = prompt('Nom de la carte :');
if (!name || !name.trim()) return;
const listCards = state.cards.filter(c => c.listId === listId);
const maxPos = listCards.reduce((m, c) => Math.max(m, c.position || 0), 0);
const res = await apiFetch('create_card', { list_id: listId, name: name.trim(), position: maxPos + 65535 });
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 listId = document.getElementById('pk-card-list').value;
const body = {
name,
description: document.getElementById('pk-card-desc').value,
dueDate: due ? due + 'T00:00:00.000Z' : null,
};
// Planka requires position whenever listId is sent — only include if list changed
if (listId !== editingCard.listId) {
body.listId = listId;
const targetCards = state.cards.filter(c => c.listId === listId);
const maxPos = targetCards.reduce((m, c) => Math.max(m, c.position || 0), 0);
body.position = maxPos + 65535;
}
const res = await apiFetch('update_card', { id: editingCard.id }, 'PATCH', body);
if (!res.ok) { showToast('Erreur : ' + (res.error || 'sauvegarde impossible'), 'error'); return; }
const idx = state.cards.findIndex(c => c.id === editingCard.id);
if (idx >= 0) state.cards[idx] = { ...state.cards[idx], ...res.data, listId: listId };
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);
}
async function promptCreateList() {
const name = prompt('Nom de la nouvelle liste :');
if (!name || !name.trim()) return;
const maxPos = state.lists.reduce((m, l) => Math.max(m, l.position || 0), 0);
const res = await apiFetch('create_list', { board_id: state.activeBoardId, name: name.trim(), position: maxPos + 65535 });
if (!res.ok) { showToast('Erreur : ' + (res.error || 'création impossible'), 'error'); return; }
state.lists.push(res.data);
renderBoard();
showToast('Liste créée ✓');
}
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'; ?>