mirror of
https://github.com/R0m1k3/noteflow.git
synced 2026-10-11 17:29:37 +02:00
feat: add Kanban board view for notes organization
- Create KanbanBoard component with drag-and-drop functionality - Support 5 columns: Backlog, À faire, En cours, En révision, Terminé - Use tag-based system to track note status in columns - Add view toggle between List and Kanban modes in desktop interface - Display note metadata (todos, images, files, tags) in Kanban cards - Support moving notes between columns by dragging - Auto-update tags when moving notes to different columns - Show task completion progress in Kanban cards
This commit is contained in:
2 files changed
+260
-3
No files matched your search
@@ -0,0 +1,196 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { CheckSquare, Image as ImageIcon, Paperclip, Tag as TagIcon, Plus, GripVertical } from "lucide-react";
|
||||||
|
import { Note } from "@/services/NotesService";
|
||||||
|
|
||||||
|
interface KanbanColumn {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_COLUMNS: KanbanColumn[] = [
|
||||||
|
{ id: 'backlog', title: 'Backlog', color: 'bg-gray-100 dark:bg-gray-800' },
|
||||||
|
{ id: 'todo', title: 'À faire', color: 'bg-blue-100 dark:bg-blue-900' },
|
||||||
|
{ id: 'in_progress', title: 'En cours', color: 'bg-yellow-100 dark:bg-yellow-900' },
|
||||||
|
{ id: 'review', title: 'En révision', color: 'bg-purple-100 dark:bg-purple-900' },
|
||||||
|
{ id: 'done', title: 'Terminé', color: 'bg-green-100 dark:bg-green-900' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface KanbanBoardProps {
|
||||||
|
notes: Note[];
|
||||||
|
onNoteClick: (note: Note) => void;
|
||||||
|
onCreateNote: (columnId: string) => void;
|
||||||
|
onMoveNote?: (noteId: number, newStatus: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KanbanBoard({ notes, onNoteClick, onCreateNote, onMoveNote }: KanbanBoardProps) {
|
||||||
|
const [draggedNote, setDraggedNote] = useState<Note | null>(null);
|
||||||
|
const [dragOverColumn, setDragOverColumn] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Group notes by their kanban_status tag or default to 'backlog'
|
||||||
|
const getNoteStatus = (note: Note): string => {
|
||||||
|
const statusTag = note.tags?.find(tag =>
|
||||||
|
DEFAULT_COLUMNS.some(col => col.id === tag.name.toLowerCase())
|
||||||
|
);
|
||||||
|
return statusTag?.name.toLowerCase() || 'backlog';
|
||||||
|
};
|
||||||
|
|
||||||
|
const notesByColumn = DEFAULT_COLUMNS.reduce((acc, column) => {
|
||||||
|
acc[column.id] = notes.filter(note =>
|
||||||
|
!note.archived && getNoteStatus(note) === column.id
|
||||||
|
);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, Note[]>);
|
||||||
|
|
||||||
|
const handleDragStart = (note: Note) => {
|
||||||
|
setDraggedNote(note);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent, columnId: string) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOverColumn(columnId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = () => {
|
||||||
|
setDragOverColumn(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent, columnId: string) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOverColumn(null);
|
||||||
|
|
||||||
|
if (draggedNote && onMoveNote && getNoteStatus(draggedNote) !== columnId) {
|
||||||
|
onMoveNote(draggedNote.id!, columnId);
|
||||||
|
}
|
||||||
|
setDraggedNote(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
setDraggedNote(null);
|
||||||
|
setDragOverColumn(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4 h-full overflow-x-auto pb-4">
|
||||||
|
{DEFAULT_COLUMNS.map((column) => (
|
||||||
|
<div
|
||||||
|
key={column.id}
|
||||||
|
className="flex-shrink-0 w-80"
|
||||||
|
onDragOver={(e) => handleDragOver(e, column.id)}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={(e) => handleDrop(e, column.id)}
|
||||||
|
>
|
||||||
|
<Card className={`h-full flex flex-col ${dragOverColumn === column.id ? 'ring-2 ring-primary' : ''}`}>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<div className={`w-3 h-3 rounded-full ${column.color}`} />
|
||||||
|
{column.title}
|
||||||
|
<Badge variant="secondary" className="ml-1">
|
||||||
|
{notesByColumn[column.id]?.length || 0}
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => onCreateNote(column.id)}
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 pt-0">
|
||||||
|
<ScrollArea className="h-[calc(100vh-250px)]">
|
||||||
|
<div className="space-y-2 pr-4">
|
||||||
|
{notesByColumn[column.id]?.map((note) => (
|
||||||
|
<div
|
||||||
|
key={note.id}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => handleDragStart(note)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onClick={() => onNoteClick(note)}
|
||||||
|
className={`p-3 border rounded-lg cursor-move hover:shadow-md transition-all group ${
|
||||||
|
draggedNote?.id === note.id ? 'opacity-50' : ''
|
||||||
|
} ${note.priority ? 'border-l-4 border-l-red-500' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2 mb-2">
|
||||||
|
<GripVertical className="h-4 w-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className="font-medium text-sm line-clamp-2 mb-1">
|
||||||
|
{note.title || "Sans titre"}
|
||||||
|
</h4>
|
||||||
|
{note.content && (
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||||
|
{note.content.replace(/<[^>]*>/g, '')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata badges */}
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
{note.todos && note.todos.length > 0 && (
|
||||||
|
<Badge variant="secondary" className="text-xs h-5">
|
||||||
|
<CheckSquare className="h-3 w-3 mr-1" />
|
||||||
|
{note.todos.filter(t => t.completed).length}/{note.todos.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{note.images && note.images.length > 0 && (
|
||||||
|
<Badge variant="secondary" className="text-xs h-5">
|
||||||
|
<ImageIcon className="h-3 w-3 mr-1" />
|
||||||
|
{note.images.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{note.files && note.files.length > 0 && (
|
||||||
|
<Badge variant="secondary" className="text-xs h-5">
|
||||||
|
<Paperclip className="h-3 w-3 mr-1" />
|
||||||
|
{note.files.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{note.tags && note.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{note.tags.slice(0, 2).map((tag) => (
|
||||||
|
<Badge key={tag.id} variant="outline" className="text-xs h-5">
|
||||||
|
<TagIcon className="h-2 w-2 mr-1" />
|
||||||
|
{tag.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{note.tags.length > 2 && (
|
||||||
|
<Badge variant="outline" className="text-xs h-5">
|
||||||
|
+{note.tags.length - 2}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date */}
|
||||||
|
{note.updated_at && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
{new Date(note.updated_at).toLocaleDateString('fr-FR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short'
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{notesByColumn[column.id]?.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||||
|
Aucune note
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+64
-3
@@ -15,7 +15,7 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command
|
|||||||
import { RichTextEditor } from "@/components/RichTextEditor";
|
import { RichTextEditor } from "@/components/RichTextEditor";
|
||||||
import {
|
import {
|
||||||
PlusCircle, Search, User, LogOut, Settings, ChevronDown, Plus, Archive, Trash2,
|
PlusCircle, Search, User, LogOut, Settings, ChevronDown, Plus, Archive, Trash2,
|
||||||
Image as ImageIcon, CheckSquare, FileText, Rss, ExternalLink, RefreshCw, Key, Zap, Paperclip, X, Edit, Calendar as CalendarIcon, Tag as TagIcon, MessageSquare, Send, Check, ChevronsUpDown, Star, Activity, FileDown
|
Image as ImageIcon, CheckSquare, FileText, Rss, ExternalLink, RefreshCw, Key, Zap, Paperclip, X, Edit, Calendar as CalendarIcon, Tag as TagIcon, MessageSquare, Send, Check, ChevronsUpDown, Star, Activity, FileDown, LayoutGrid, List
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import AuthService from "@/services/AuthService";
|
import AuthService from "@/services/AuthService";
|
||||||
import AdminService from "@/services/AdminService";
|
import AdminService from "@/services/AdminService";
|
||||||
@@ -38,6 +38,7 @@ import { TemplateSelector } from "@/components/TemplateSelector";
|
|||||||
import type { NoteTemplate } from "@/utils/noteTemplates";
|
import type { NoteTemplate } from "@/utils/noteTemplates";
|
||||||
import { AdvancedSearch, type SearchFilters, type TagOption } from "@/components/AdvancedSearch";
|
import { AdvancedSearch, type SearchFilters, type TagOption } from "@/components/AdvancedSearch";
|
||||||
import { PomodoroTimer } from "@/components/PomodoroTimer";
|
import { PomodoroTimer } from "@/components/PomodoroTimer";
|
||||||
|
import { KanbanBoard } from "@/components/KanbanBoard";
|
||||||
|
|
||||||
// ===== FONCTIONS UTILITAIRES TIMEZONE EUROPE/PARIS =====
|
// ===== FONCTIONS UTILITAIRES TIMEZONE EUROPE/PARIS =====
|
||||||
|
|
||||||
@@ -136,6 +137,7 @@ const Index = () => {
|
|||||||
const [settings, setSettings] = useState<AppSettings>({});
|
const [settings, setSettings] = useState<AppSettings>({});
|
||||||
const [adminTab, setAdminTab] = useState("users");
|
const [adminTab, setAdminTab] = useState("users");
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
const [notesViewMode, setNotesViewMode] = useState<'list' | 'kanban'>('list');
|
||||||
const [noteTags, setNoteTags] = useState<Tag[]>([]);
|
const [noteTags, setNoteTags] = useState<Tag[]>([]);
|
||||||
const [openRouterModels, setOpenRouterModels] = useState<OpenRouterModel[]>([]);
|
const [openRouterModels, setOpenRouterModels] = useState<OpenRouterModel[]>([]);
|
||||||
const [loadingModels, setLoadingModels] = useState(false);
|
const [loadingModels, setLoadingModels] = useState(false);
|
||||||
@@ -1229,11 +1231,35 @@ const Index = () => {
|
|||||||
onFiltersChange={setSearchFilters}
|
onFiltersChange={setSearchFilters}
|
||||||
availableTags={availableTags}
|
availableTags={availableTags}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* View Mode Toggle */}
|
||||||
|
<div className="flex gap-1 border rounded-md p-1">
|
||||||
|
<Button
|
||||||
|
variant={notesViewMode === 'list' ? 'default' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setNotesViewMode('list')}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
Liste
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={notesViewMode === 'kanban' ? 'default' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setNotesViewMode('kanban')}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<LayoutGrid className="h-4 w-4" />
|
||||||
|
Kanban
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notes Grid - 1 column for better visibility */}
|
{/* Notes View - List or Kanban */}
|
||||||
<div className="grid grid-cols-1 gap-4">
|
{notesViewMode === 'list' ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
{filteredNotes.length > 0 ? (
|
{filteredNotes.length > 0 ? (
|
||||||
paginatedNotes.map(note => (
|
paginatedNotes.map(note => (
|
||||||
<Card
|
<Card
|
||||||
@@ -1371,6 +1397,41 @@ const Index = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
/* Kanban View */
|
||||||
|
<KanbanBoard
|
||||||
|
notes={filteredNotes}
|
||||||
|
onNoteClick={handleOpenNote}
|
||||||
|
onCreateNote={(columnId) => {
|
||||||
|
setTemplateSelectorOpen(true);
|
||||||
|
// TODO: Set initial tag based on columnId
|
||||||
|
}}
|
||||||
|
onMoveNote={async (noteId, newStatus) => {
|
||||||
|
// Move note to new column by updating its status tag
|
||||||
|
const note = notes.find(n => n.id === noteId);
|
||||||
|
if (!note) return;
|
||||||
|
|
||||||
|
// Remove old status tags
|
||||||
|
const oldStatusTags = note.tags?.filter(tag =>
|
||||||
|
['backlog', 'todo', 'in_progress', 'review', 'done'].includes(tag.name.toLowerCase())
|
||||||
|
) || [];
|
||||||
|
|
||||||
|
for (const tag of oldStatusTags) {
|
||||||
|
if (tag.id && note.id) {
|
||||||
|
await TagsService.deleteTag(note.id, tag.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new status tag
|
||||||
|
if (note.id) {
|
||||||
|
await TagsService.addTag(note.id, newStatus);
|
||||||
|
await loadNotes(); // Refresh notes
|
||||||
|
showSuccess(`Note déplacée vers ${newStatus}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
/* Open Note Editor */
|
/* Open Note Editor */
|
||||||
|
|||||||
Reference in new issue
Block a user