feat: enhance mobile and desktop layouts in Daily and Kanban pages

- Refactored `DailyPageClient` to prioritize mobile layout with today's section first and calendar at the bottom for better usability.
- Updated `KanbanPageClient` to include responsive controls for mobile, improving task management experience.
- Adjusted `DailyCheckboxItem` and `DailySection` for better touch targets and responsive design.
- Cleaned up `TODO.md` to reflect changes in mobile interface considerations and task management features.
This commit is contained in:
Julien Froidefond
2025-09-21 21:37:30 +02:00
parent 2194744eef
commit 361fc0eaac
9 changed files with 435 additions and 217 deletions

37
TODO.md
View File

@@ -124,43 +124,6 @@
- **Performance** : Index sur `userId`, pagination pour gros volumes - **Performance** : Index sur `userId`, pagination pour gros volumes
- **Migration** : Script de migration des données existantes - **Migration** : Script de migration des données existantes
### 📱 Interface mobile adaptée (PROJET MAJEUR)
#### **Problème actuel**
- Kanban non adapté aux écrans tactiles petits
- Drag & drop difficile sur mobile
- Interface desktop-first
#### **Solution : Interface mobile dédiée**
- [ ] **Phase 1: Détection et responsive**
- [ ] Détection mobile/desktop (useMediaQuery)
- [ ] Composant de switch automatique d'interface
- [ ] Breakpoints adaptés pour tablettes
- [ ] **Phase 2: Interface mobile pour les tâches**
- [ ] **Vue liste simple** : Kanban simple OK, mais swimlane KO. Ajouter une autre interface plus simple pour mobile en plus du Kanban Simple
- [ ] Liste verticale avec statuts en badges
- [ ] Actions par swipe (marquer terminé, changer statut)
- [ ] Filtres simplifiés (dropdown au lieu de sidebar)
- [ ] **Actions tactiles**
- [ ] Tap pour voir détails
- [ ] Long press pour menu contextuel
- [ ] Swipe left/right pour actions rapides
- [ ] **Navigation mobile**
- [ ] Bottom navigation bar
- [ ] Sections : Tâches, Daily, Jira, Profil
- [ ] **Phase 3: Daily mobile optimisé**
- [ ] Checkboxes plus grandes (touch-friendly)
- [ ] Ajout rapide par bouton flottant
- [ ] On ne voit que aujourd'hui et au swipe on va en avant en arrière par jour
#### **Considérations UX mobile**
- **Simplicité** : Moins d'options visibles, plus de navigation
- **Tactile** : Boutons plus grands, zones de touch optimisées
- **Performance** : Lazy loading, virtualisation pour longues listes
- **Offline** : Cache local pour usage sans réseau (PWA)
--- ---
*Focus sur l'expérience utilisateur et le design moderne. App standalone prête pour évoluer vers une plateforme d'intégration complète.* *Focus sur l'expérience utilisateur et le design moderne. App standalone prête pour évoluer vers une plateforme d'intégration complète.*

View File

@@ -212,8 +212,39 @@ export function DailyPageClient({
{/* Contenu principal */} {/* Contenu principal */}
<main className="container mx-auto px-4 py-8"> <main className="container mx-auto px-4 py-8">
{/* Layout Mobile uniquement - Section Aujourd'hui en premier */}
<div className="block sm:hidden">
{dailyView && (
<div className="space-y-6">
{/* Section Aujourd'hui - Mobile First */}
<DailySection
title={getTodayTitle()}
date={getTodayDate()}
checkboxes={dailyView.today}
onAddCheckbox={handleAddTodayCheckbox}
onToggleCheckbox={handleToggleCheckbox}
onUpdateCheckbox={handleUpdateCheckbox}
onDeleteCheckbox={handleDeleteCheckbox}
onReorderCheckboxes={handleReorderCheckboxes}
onToggleAll={toggleAllToday}
saving={saving}
refreshing={refreshing}
/>
{/* Calendrier en bas sur mobile */}
<DailyCalendar
currentDate={currentDate}
onDateSelect={handleDateSelect}
dailyDates={dailyDates}
/>
</div>
)}
</div>
{/* Layout Tablette/Desktop - Layout original */}
<div className="hidden sm:block">
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6"> <div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
{/* Calendrier - toujours visible */} {/* Calendrier - Desktop */}
<div className="xl:col-span-1"> <div className="xl:col-span-1">
<DailyCalendar <DailyCalendar
currentDate={currentDate} currentDate={currentDate}
@@ -222,10 +253,10 @@ export function DailyPageClient({
/> />
</div> </div>
{/* Sections daily */} {/* Sections daily - Desktop */}
{dailyView && ( {dailyView && (
<div className="xl:col-span-2 grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="xl:col-span-2 grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Section Hier */} {/* Section Hier - Desktop seulement */}
<DailySection <DailySection
title={getYesterdayTitle()} title={getYesterdayTitle()}
date={getYesterdayDate()} date={getYesterdayDate()}
@@ -240,7 +271,7 @@ export function DailyPageClient({
refreshing={refreshing} refreshing={refreshing}
/> />
{/* Section Aujourd'hui */} {/* Section Aujourd'hui - Desktop */}
<DailySection <DailySection
title={getTodayTitle()} title={getTodayTitle()}
date={getTodayDate()} date={getTodayDate()}
@@ -257,6 +288,7 @@ export function DailyPageClient({
</div> </div>
)} )}
</div> </div>
</div>
{/* Section des tâches en attente */} {/* Section des tâches en attente */}
<PendingTasksSection <PendingTasksSection

View File

@@ -11,6 +11,8 @@ import { CreateTaskForm } from '@/components/forms/CreateTaskForm';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { JiraQuickFilter } from '@/components/kanban/JiraQuickFilter'; import { JiraQuickFilter } from '@/components/kanban/JiraQuickFilter';
import { FontSizeToggle } from '@/components/ui/FontSizeToggle'; import { FontSizeToggle } from '@/components/ui/FontSizeToggle';
import { MobileControls } from '@/components/kanban/MobileControls';
import { useIsMobile } from '@/hooks/useIsMobile';
interface KanbanPageClientProps { interface KanbanPageClientProps {
initialTasks: Task[]; initialTasks: Task[];
@@ -21,6 +23,7 @@ function KanbanPageContent() {
const { syncing, createTask, activeFiltersCount, kanbanFilters, setKanbanFilters } = useTasksContext(); const { syncing, createTask, activeFiltersCount, kanbanFilters, setKanbanFilters } = useTasksContext();
const { preferences, updateViewPreferences } = useUserPreferences(); const { preferences, updateViewPreferences } = useUserPreferences();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const isMobile = useIsMobile(768); // Tailwind md breakpoint
// Extraire les préférences du context // Extraire les préférences du context
const showFilters = preferences.viewPreferences.showFilters; const showFilters = preferences.viewPreferences.showFilters;
@@ -59,7 +62,22 @@ function KanbanPageContent() {
syncing={syncing} syncing={syncing}
/> />
{/* Barre de contrôles de visibilité */} {/* Barre de contrôles responsive */}
{isMobile ? (
<MobileControls
showFilters={showFilters}
showObjectives={showObjectives}
compactView={compactView}
activeFiltersCount={activeFiltersCount}
kanbanFilters={kanbanFilters}
onToggleFilters={handleToggleFilters}
onToggleObjectives={handleToggleObjectives}
onToggleCompactView={handleToggleCompactView}
onFiltersChange={setKanbanFilters}
onCreateTask={() => setIsCreateModalOpen(true)}
/>
) : (
/* Barre de contrôles desktop */
<div className="bg-[var(--card)]/30 border-b border-[var(--border)]/30"> <div className="bg-[var(--card)]/30 border-b border-[var(--border)]/30">
<div className="container mx-auto px-6 py-2"> <div className="container mx-auto px-6 py-2">
<div className="flex items-center justify-between w-full"> <div className="flex items-center justify-between w-full">
@@ -159,6 +177,7 @@ function KanbanPageContent() {
</div> </div>
</div> </div>
</div> </div>
)}
<main className="h-[calc(100vh-160px)]"> <main className="h-[calc(100vh-160px)]">
<KanbanBoardContainer <KanbanBoardContainer

View File

@@ -74,7 +74,7 @@ export function DailyCheckboxItem({
return ( return (
<> <>
<div className={`flex items-center gap-2 px-3 py-1.5 rounded border transition-colors group ${ <div className={`flex items-center gap-3 px-3 py-2 sm:py-1.5 sm:gap-2 rounded border transition-colors group ${
checkbox.type === 'meeting' checkbox.type === 'meeting'
? 'border-l-4 border-l-blue-500 border-t-[var(--border)]/30 border-r-[var(--border)]/30 border-b-[var(--border)]/30 hover:border-t-[var(--border)] hover:border-r-[var(--border)] hover:border-b-[var(--border)]' ? 'border-l-4 border-l-blue-500 border-t-[var(--border)]/30 border-r-[var(--border)]/30 border-b-[var(--border)]/30 hover:border-t-[var(--border)] hover:border-r-[var(--border)] hover:border-b-[var(--border)]'
: 'border-l-4 border-l-green-500 border-t-[var(--border)]/30 border-r-[var(--border)]/30 border-b-[var(--border)]/30 hover:border-t-[var(--border)] hover:border-r-[var(--border)] hover:border-b-[var(--border)]' : 'border-l-4 border-l-green-500 border-t-[var(--border)]/30 border-r-[var(--border)]/30 border-b-[var(--border)]/30 hover:border-t-[var(--border)] hover:border-r-[var(--border)] hover:border-b-[var(--border)]'
@@ -85,7 +85,7 @@ export function DailyCheckboxItem({
checked={checkbox.isChecked} checked={checkbox.isChecked}
onChange={() => onToggle(checkbox.id)} onChange={() => onToggle(checkbox.id)}
disabled={saving} disabled={saving}
className="w-3.5 h-3.5 rounded border border-[var(--border)] text-[var(--primary)] focus:ring-[var(--primary)]/20 focus:ring-1" className="w-4 h-4 md:w-3.5 md:h-3.5 rounded border border-[var(--border)] text-[var(--primary)] focus:ring-[var(--primary)]/20 focus:ring-1"
/> />
{/* Contenu principal */} {/* Contenu principal */}
@@ -102,7 +102,7 @@ export function DailyCheckboxItem({
<div className="flex-1 flex items-center gap-2"> <div className="flex-1 flex items-center gap-2">
{/* Texte cliquable pour édition inline */} {/* Texte cliquable pour édition inline */}
<span <span
className={`flex-1 text-xs font-mono transition-all cursor-pointer hover:bg-[var(--muted)]/50 py-0.5 px-1 rounded ${ className={`flex-1 text-sm sm:text-xs font-mono transition-all cursor-pointer hover:bg-[var(--muted)]/50 py-0.5 px-1 rounded ${
checkbox.isChecked checkbox.isChecked
? 'line-through text-[var(--muted-foreground)]' ? 'line-through text-[var(--muted-foreground)]'
: 'text-[var(--foreground)]' : 'text-[var(--foreground)]'

View File

@@ -87,7 +87,7 @@ export function DailySection({
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
id={`daily-dnd-${title.replace(/[^a-zA-Z0-9]/g, '-')}`} id={`daily-dnd-${title.replace(/[^a-zA-Z0-9]/g, '-')}`}
> >
<Card className="p-0 flex flex-col h-[600px]"> <Card className="p-0 flex flex-col h-[80vh] sm:h-[600px]">
{/* Header */} {/* Header */}
<div className="p-4 pb-0"> <div className="p-4 pb-0">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">

View File

@@ -6,6 +6,7 @@ import { PrioritySwimlanesBoard } from './PrioritySwimlanesBoard';
import { Task, TaskStatus } from '@/lib/types'; import { Task, TaskStatus } from '@/lib/types';
import { CreateTaskData } from '@/clients/tasks-client'; import { CreateTaskData } from '@/clients/tasks-client';
import { KanbanFilters } from './KanbanFilters'; import { KanbanFilters } from './KanbanFilters';
import { useIsMobile } from '@/hooks/useIsMobile';
interface BoardRouterProps { interface BoardRouterProps {
tasks: Task[]; tasks: Task[];
@@ -26,8 +27,13 @@ export function BoardRouter({
visibleStatuses, visibleStatuses,
loading loading
}: BoardRouterProps) { }: BoardRouterProps) {
const isMobile = useIsMobile(768); // Tailwind md breakpoint
// Sur mobile, toujours utiliser le board standard pour une meilleure UX
const shouldUseSwimlanes = kanbanFilters.swimlanesByTags && !isMobile;
// Logique de routage des boards selon les filtres // Logique de routage des boards selon les filtres
if (kanbanFilters.swimlanesByTags) { if (shouldUseSwimlanes) {
if (kanbanFilters.swimlanesMode === 'priority') { if (kanbanFilters.swimlanesMode === 'priority') {
return ( return (
<PrioritySwimlanesBoard <PrioritySwimlanesBoard

View File

@@ -10,6 +10,7 @@ import { getAllPriorities, getPriorityColorHex } from '@/lib/status-config';
import { SORT_OPTIONS } from '@/lib/sort-config'; import { SORT_OPTIONS } from '@/lib/sort-config';
import { useUserPreferences } from '@/contexts/UserPreferencesContext'; import { useUserPreferences } from '@/contexts/UserPreferencesContext';
import { ColumnVisibilityToggle } from './ColumnVisibilityToggle'; import { ColumnVisibilityToggle } from './ColumnVisibilityToggle';
import { useIsMobile } from '@/hooks/useIsMobile';
export interface KanbanFilters { export interface KanbanFilters {
search?: string; search?: string;
@@ -44,6 +45,7 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH
const toggleStatusVisibility = onToggleStatusVisibility || toggleColumnVisibility; const toggleStatusVisibility = onToggleStatusVisibility || toggleColumnVisibility;
const [isSortExpanded, setIsSortExpanded] = useState(false); const [isSortExpanded, setIsSortExpanded] = useState(false);
const [isSwimlaneModeExpanded, setIsSwimlaneModeExpanded] = useState(false); const [isSwimlaneModeExpanded, setIsSwimlaneModeExpanded] = useState(false);
const isMobile = useIsMobile(768); // Tailwind md breakpoint
const sortDropdownRef = useRef<HTMLDivElement>(null); const sortDropdownRef = useRef<HTMLDivElement>(null);
const swimlaneModeDropdownRef = useRef<HTMLDivElement>(null); const swimlaneModeDropdownRef = useRef<HTMLDivElement>(null);
const sortButtonRef = useRef<HTMLButtonElement>(null); const sortButtonRef = useRef<HTMLButtonElement>(null);
@@ -262,7 +264,8 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH
/> />
</div> </div>
{/* Menu swimlanes */} {/* Menu swimlanes - masqué sur mobile */}
{!isMobile && (
<div className="flex gap-1"> <div className="flex gap-1">
<Button <Button
variant={filters.swimlanesByTags ? "primary" : "ghost"} variant={filters.swimlanesByTags ? "primary" : "ghost"}
@@ -308,6 +311,7 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH
</Button> </Button>
)} )}
</div> </div>
)}
{/* Bouton de tri */} {/* Bouton de tri */}
@@ -600,8 +604,8 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH
document.body document.body
)} )}
{/* Dropdown des modes swimlanes rendu via portail pour éviter les problèmes de z-index */} {/* Dropdown des modes swimlanes rendu via portail pour éviter les problèmes de z-index - masqué sur mobile */}
{isSwimlaneModeExpanded && typeof window !== 'undefined' && createPortal( {!isMobile && isSwimlaneModeExpanded && typeof window !== 'undefined' && createPortal(
<div <div
ref={swimlaneModeDropdownRef} ref={swimlaneModeDropdownRef}
className="fixed bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-xl z-[9999] min-w-[140px]" className="fixed bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-xl z-[9999] min-w-[140px]"

View File

@@ -0,0 +1,165 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/Button';
import { JiraQuickFilter } from '@/components/kanban/JiraQuickFilter';
import { FontSizeToggle } from '@/components/ui/FontSizeToggle';
import { KanbanFilters } from '@/components/kanban/KanbanFilters';
interface MobileControlsProps {
showFilters: boolean;
showObjectives: boolean;
compactView: boolean;
activeFiltersCount: number;
kanbanFilters: KanbanFilters;
onToggleFilters: () => void;
onToggleObjectives: () => void;
onToggleCompactView: () => void;
onFiltersChange: (filters: KanbanFilters) => void;
onCreateTask: () => void;
}
export function MobileControls({
showFilters,
showObjectives,
compactView,
activeFiltersCount,
kanbanFilters,
onToggleFilters,
onToggleObjectives,
onToggleCompactView,
onFiltersChange,
onCreateTask,
}: MobileControlsProps) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
return (
<div className="bg-[var(--card)]/30 border-b border-[var(--border)]/30">
<div className="px-4 py-2">
{/* Barre principale mobile */}
<div className="flex items-center justify-between">
{/* Bouton menu hamburger */}
<button
onClick={() => setIsMenuOpen(!isMenuOpen)}
className="flex items-center gap-2 px-3 py-2 rounded-md bg-[var(--card)] border border-[var(--border)] text-[var(--muted-foreground)] hover:border-[var(--primary)]/50 transition-all"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
<span className="text-sm font-mono">Options</span>
{activeFiltersCount > 0 && (
<span className="bg-[var(--primary)]/20 text-[var(--primary)] text-xs px-1.5 py-0.5 rounded-full font-mono">
{activeFiltersCount}
</span>
)}
</button>
{/* Bouton d'ajout de tâche */}
<Button
variant="primary"
onClick={onCreateTask}
className="flex items-center gap-2"
size="sm"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
<span className="hidden xs:inline">Nouvelle</span>
</Button>
</div>
{/* Menu déroulant */}
{isMenuOpen && (
<div className="mt-3 p-3 bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-lg">
{/* Section Affichage */}
<div className="mb-4">
<h3 className="text-xs font-mono text-[var(--muted-foreground)] uppercase tracking-wide mb-2">
Affichage
</h3>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => {
onToggleFilters();
setIsMenuOpen(false);
}}
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-mono transition-all ${
showFilters
? 'bg-[var(--primary)]/20 text-[var(--primary)] border border-[var(--primary)]/30'
: 'bg-[var(--muted)]/30 text-[var(--muted-foreground)] border border-[var(--border)]'
}`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4" />
</svg>
Filtres
</button>
<button
onClick={() => {
onToggleObjectives();
setIsMenuOpen(false);
}}
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-mono transition-all ${
showObjectives
? 'bg-[var(--accent)]/20 text-[var(--accent)] border border-[var(--accent)]/30'
: 'bg-[var(--muted)]/30 text-[var(--muted-foreground)] border border-[var(--border)]'
}`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z" />
</svg>
Objectifs
</button>
</div>
</div>
{/* Section Paramètres */}
<div className="mb-4">
<h3 className="text-xs font-mono text-[var(--muted-foreground)] uppercase tracking-wide mb-2">
Paramètres
</h3>
<div className="space-y-2">
<button
onClick={() => {
onToggleCompactView();
setIsMenuOpen(false);
}}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm font-mono transition-all ${
compactView
? 'bg-[var(--secondary)]/20 text-[var(--secondary)] border border-[var(--secondary)]/30'
: 'bg-[var(--muted)]/30 text-[var(--muted-foreground)] border border-[var(--border)]'
}`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{compactView ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
)}
</svg>
Vue {compactView ? 'détaillée' : 'compacte'}
</button>
<div className="flex items-center justify-between px-3 py-2 bg-[var(--muted)]/30 border border-[var(--border)] rounded-md">
<span className="text-sm font-mono text-[var(--muted-foreground)]">Taille police</span>
<FontSizeToggle />
</div>
</div>
</div>
{/* Section Jira */}
<div>
<h3 className="text-xs font-mono text-[var(--muted-foreground)] uppercase tracking-wide mb-2">
Raccourcis Jira
</h3>
<JiraQuickFilter
filters={kanbanFilters}
onFiltersChange={onFiltersChange}
/>
</div>
</div>
)}
</div>
</div>
);
}

29
src/hooks/useIsMobile.ts Normal file
View File

@@ -0,0 +1,29 @@
'use client';
import { useState, useEffect } from 'react';
/**
* Hook pour détecter si l'utilisateur est sur mobile
* Utilise un breakpoint à 640px (sm en Tailwind)
*/
export function useIsMobile(breakpoint: number = 640): boolean {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const checkIsMobile = () => {
setIsMobile(window.innerWidth < breakpoint);
};
// Check initial
checkIsMobile();
// Écouter les changements de taille
window.addEventListener('resize', checkIsMobile);
return () => {
window.removeEventListener('resize', checkIsMobile);
};
}, [breakpoint]);
return isMobile;
}