diff --git a/backend/app/routes/kanban/boards.py b/backend/app/routes/kanban/boards.py index b69ed4e..05c6455 100644 --- a/backend/app/routes/kanban/boards.py +++ b/backend/app/routes/kanban/boards.py @@ -27,12 +27,15 @@ def get_boards(): @load_board_owned def get_board(board_id, board): """Get a single board with all its details""" + from app.models import User + # Get all lists for this board lists_data = [] for lst in board.lists.filter_by(closed=False).order_by(List.pos).all(): cards_data = [] for card in lst.cards.filter_by(closed=False).order_by(Card.pos).all(): card_dict = card.to_dict() + # Add labels for this card card_dict["labels"] = [ label.to_dict() @@ -43,6 +46,24 @@ def get_board(board_id, board): .all() ) ] + + # Add comments for this card + card_dict["comments"] = [] + for comment in card.comments.all(): + comment_dict = comment.to_dict() + user = db.session.get(User, comment.user_id) + comment_dict["user"] = user.to_dict() if user else None + card_dict["comments"].append(comment_dict) + + # Add checklists with items for this card + card_dict["checklists"] = [ + { + **checklist.to_dict(), + "items": [item.to_dict() for item in checklist.check_items.all()], + } + for checklist in card.checklists.all() + ] + cards_data.append(card_dict) lists_data.append({**lst.to_dict(), "cards": cards_data}) diff --git a/backend/app/schemas/board.py b/backend/app/schemas/board.py index ef98a32..89fd41e 100644 --- a/backend/app/schemas/board.py +++ b/backend/app/schemas/board.py @@ -105,3 +105,5 @@ class BoardWithDetailsResponse(BaseModel): date_last_activity: Optional[datetime] = None lists: List[Dict[str, Any]] = Field(default_factory=list) labels: List[Dict[str, Any]] = Field(default_factory=list) + checklists: List[Dict[str, Any]] = Field(default_factory=list) + comments: List[Dict[str, Any]] = Field(default_factory=list) diff --git a/frontend/src/components/CardPreviewModal.tsx b/frontend/src/components/CardPreviewModal.tsx new file mode 100644 index 0000000..5e3826f --- /dev/null +++ b/frontend/src/components/CardPreviewModal.tsx @@ -0,0 +1,155 @@ +import { CardWithDetails } from '../types/kanban'; +import { Link } from 'react-router-dom'; + +interface CardPreviewModalProps { + card: CardWithDetails; + onClose: () => void; +} + +export function CardPreviewModal({ card, onClose }: CardPreviewModalProps) { + return ( +
+
+ + {card.name} + + +
+ + {/* Labels */} + {card.labels && card.labels.length > 0 && ( +
+
+ {card.labels.map((label) => ( + + {label.name} + + ))} +
+
+ )} + + {/* Description */} + {card.description && ( +
+

Description

+

{card.description}

+
+ )} + + {/* Checklists */} + {card.checklists && card.checklists.length > 0 && ( +
+

Checklists

+
+ {card.checklists.map((checklist) => { + const completedItems = checklist.items.filter( + (item) => item.state === 'complete' + ).length; + const totalItems = checklist.items.length; + const progress = totalItems > 0 ? (completedItems / totalItems) * 100 : 0; + + return ( +
+
+

{checklist.name}

+ + {completedItems}/{totalItems} + +
+
+
+
+ {/* Checklist Items */} +
+ {checklist.items.map((item) => ( +
+
+ {item.state === 'complete' && ( + + + + )} +
+ + {item.name} + +
+ ))} +
+
+ ); + })} +
+
+ )} + + {/* Comments Preview */} + {card.comments && card.comments.length > 0 && ( +
+

Comments

+
+ {card.comments.slice(0, 3).map((comment) => ( +
+
+
+ {comment.user?.username.charAt(0).toUpperCase() || '?'} +
+
+

+ {comment.user?.username || 'Unknown'} +

+

+ {new Date(comment.created_at).toLocaleDateString()} +

+
+
+

{comment.text}

+
+ ))} + {card.comments.length > 3 && ( +

+ +{card.comments.length - 3} more comments +

+ )} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/kanban/KanbanCard.tsx b/frontend/src/components/kanban/KanbanCard.tsx index 3ad5740..30fbd1a 100644 --- a/frontend/src/components/kanban/KanbanCard.tsx +++ b/frontend/src/components/kanban/KanbanCard.tsx @@ -1,13 +1,16 @@ import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { Card as CardType } from '../../types/kanban'; +import MessageSquareIcon from '../icons/MessageSquareIcon'; +import CheckSquareIcon from '../icons/CheckSquareIcon'; +import { Link } from 'react-router-dom'; interface KanbanCardProps { card: CardType; - onClick: () => void; + onOpenModal: () => void; } -export function KanbanCard({ card, onClick }: KanbanCardProps) { +export function KanbanCard({ card, onOpenModal }: KanbanCardProps) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: `CARD_${card.id}`, }); @@ -18,28 +21,109 @@ export function KanbanCard({ card, onClick }: KanbanCardProps) { opacity: isDragging ? 0.5 : 1, }; + // Calculate checklist progress + const checklists = (card as any).checklists || []; + const totalItems = checklists.reduce( + (sum: number, checklist: any) => sum + checklist.items.length, + 0 + ); + const completedItems = checklists.reduce( + (sum: number, checklist: any) => + sum + checklist.items.filter((item: any) => item.state === 'complete').length, + 0 + ); + const hasChecklists = totalItems > 0; + + // Calculate comment count + const comments = (card as any).comments || []; + const commentCount = comments.length; + const hasComments = commentCount > 0; + + // Get labels + const labels = (card as any).labels || []; + const hasLabels = labels.length > 0; + return (
{ + // Only open modal if not clicking title link + if (!(e.target as HTMLElement).closest('a')) { + onOpenModal(); + } + }} className="bg-gray-700 rounded-lg p-4 mb-3 cursor-pointer hover:bg-gray-600 transition-colors border border-gray-600 shadow-sm" > -

{card.name}

- - {card.description && ( -

{card.description}

+ {/* Labels */} + {hasLabels && ( +
+ {labels.slice(0, 3).map((label: any) => ( + + {label.name} + + ))} + {labels.length > 3 && ( + + +{labels.length - 3} + + )} +
)} -
- #{card.id_short} +

+ e.stopPropagation()} + className="hover:underline" + > + {card.name} + +

+ + {card.description && ( +

{card.description}

+ )} + + {/* Badges */} +
+ {/* Comments Badge */} + {hasComments && ( +
+ + + + {commentCount} +
+ )} + + {/* Checklist Badge */} + {hasChecklists && ( +
+ + + + + {completedItems}/{totalItems} + +
+ )} + + {/* Due Date Badge */} {card.due && ( Due: {new Date(card.due).toLocaleDateString()} )} + + {/* Card ID */} + #{card.id_short}
); diff --git a/frontend/src/components/kanban/KanbanColumn.tsx b/frontend/src/components/kanban/KanbanColumn.tsx index f2acb25..617dd40 100644 --- a/frontend/src/components/kanban/KanbanColumn.tsx +++ b/frontend/src/components/kanban/KanbanColumn.tsx @@ -8,11 +8,11 @@ import { useModal } from '../../context/modals/useModal'; interface KanbanColumnProps { list: ListWithCards; cards: CardType[]; - onCardClick: (card: CardType) => void; + onOpenCardModal: (card: CardType) => void; onCardCreate: (data: { name: string; description?: string }) => Promise; } -export function KanbanColumn({ list, cards, onCardClick, onCardCreate }: KanbanColumnProps) { +export function KanbanColumn({ list, cards, onOpenCardModal, onCardCreate }: KanbanColumnProps) { const { setNodeRef, isOver } = useDroppable({ id: `LIST_${list.id}`, }); @@ -42,7 +42,7 @@ export function KanbanColumn({ list, cards, onCardClick, onCardCreate }: KanbanC className={`min-h-[200px] flex-1 transition-colors ${isOver ? 'bg-gray-750' : ''}`} > {cards.map((card) => ( - onCardClick(card)} /> + onOpenCardModal(card)} /> ))}
diff --git a/frontend/src/pages/BoardDetail.tsx b/frontend/src/pages/BoardDetail.tsx index a6f7fc5..ae173e8 100644 --- a/frontend/src/pages/BoardDetail.tsx +++ b/frontend/src/pages/BoardDetail.tsx @@ -1,9 +1,10 @@ -import { useParams, Link, useNavigate } from 'react-router-dom'; +import { useParams, Link } from 'react-router-dom'; import { useBoard } from '../hooks/useBoard'; import { useCardMutations } from '../hooks/useCardMutations'; import { useListMutations } from '../hooks/useListMutations'; import { KanbanColumn } from '../components/kanban/KanbanColumn'; import { CreateListModal } from '../components/kanban/CreateListModal'; +import { CardPreviewModal } from '../components/CardPreviewModal'; import { useModal } from '../context/modals/useModal'; import { DndContext, @@ -21,7 +22,6 @@ import { useState } from 'react'; export function BoardDetail() { const { id } = useParams<{ id: string }>(); - const navigate = useNavigate(); const { board, fetchBoard } = useBoard(parseInt(id || '0')); const { createCard, moveCard } = useCardMutations(parseInt(id || '0'), fetchBoard); const { createList } = useListMutations(parseInt(id || '0'), fetchBoard); @@ -160,8 +160,8 @@ export function BoardDetail() { await moveCard(activeCard, activeList.id, overListContainingCard.id, overCardIndex); }; - const handleCardClick = (card: CardType) => { - navigate(`/boards/${id}/cards/${card.id}`); + const handleOpenCardModal = (card: CardType) => { + openModal((props) => ); }; const handleAddList = () => { @@ -229,7 +229,7 @@ export function BoardDetail() { key={list.id} list={list} cards={list.cards} - onCardClick={handleCardClick} + onOpenCardModal={handleOpenCardModal} onCardCreate={handleAddCard(list.id)} /> ))}