Skip to content

Commit d97fc50

Browse files
authored
Merge pull request #115 from tribeti/feature/search-task
feat: implement Kanban board component with column management and deb…
2 parents 2955fd4 + 38ebafe commit d97fc50

4 files changed

Lines changed: 147 additions & 57 deletions

File tree

src/app/(dashboard)/projects/page.tsx

Lines changed: 27 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,13 @@ import { toast } from "sonner";
2121
import { useRouter, useSearchParams } from "next/navigation";
2222
import { Suspense } from "react";
2323

24-
function ProjectUrlHandler({
25-
ownedBoards,
26-
joinedBoards,
27-
boardsLoading,
28-
selectedProjectId,
29-
currentTab,
30-
onProjectFound
24+
function ProjectUrlHandler({
25+
ownedBoards,
26+
joinedBoards,
27+
boardsLoading,
28+
selectedProjectId,
29+
currentTab,
30+
onProjectFound
3131
}: {
3232
ownedBoards: Board[];
3333
joinedBoards: Board[];
@@ -265,20 +265,20 @@ export default function ProjectsPage() {
265265
{boardsLoading
266266
? renderSkeletonCards()
267267
: ownedBoards.map((proj, index) => (
268-
<ProjectCard
269-
key={proj.id}
270-
proj={proj}
271-
index={index}
272-
openMenuProjectId={openMenuProjectId}
273-
setOpenMenuProjectId={setOpenMenuProjectId}
274-
menuRef={menuRef}
275-
handleUpdateProject={handleUpdateProject}
276-
handleDeleteProject={handleDeleteProject}
277-
setSelectedProject={setSelectedProject}
278-
currentUserId={userId}
279-
memberRole="Owner"
280-
/>
281-
))}
268+
<ProjectCard
269+
key={proj.id}
270+
proj={proj}
271+
index={index}
272+
openMenuProjectId={openMenuProjectId}
273+
setOpenMenuProjectId={setOpenMenuProjectId}
274+
menuRef={menuRef}
275+
handleUpdateProject={handleUpdateProject}
276+
handleDeleteProject={handleDeleteProject}
277+
setSelectedProject={setSelectedProject}
278+
currentUserId={userId}
279+
memberRole="Owner"
280+
/>
281+
))}
282282

283283
{/* Create New Project Card */}
284284
<div
@@ -336,7 +336,7 @@ export default function ProjectsPage() {
336336
}
337337
>
338338
<Suspense fallback={null}>
339-
<ProjectUrlHandler
339+
<ProjectUrlHandler
340340
ownedBoards={ownedBoards}
341341
joinedBoards={joinedBoards}
342342
boardsLoading={boardsLoading}
@@ -480,13 +480,12 @@ export default function ProjectsPage() {
480480
</div>
481481

482482
{/* FLOATING ACTION BUTTON */}
483-
{selectedProject ? (
483+
{selectedProject && (
484484
<button
485-
className={`absolute bottom-8 right-8 w-14 h-14 transition-transform hover:scale-105 rounded-full flex items-center justify-center shadow-lg text-white z-20 ${
486-
projectTab === "Timeline"
487-
? "bg-[#1E293B] shadow-slate-400"
488-
: "bg-[#34D399] shadow-emerald-200"
489-
}`}
485+
className={`absolute bottom-8 right-8 w-14 h-14 transition-transform hover:scale-105 rounded-full flex items-center justify-center shadow-lg text-white z-20 ${projectTab === "Timeline"
486+
? "bg-[#1E293B] shadow-slate-400"
487+
: "bg-[#34D399] shadow-emerald-200"
488+
}`}
490489
>
491490
{projectTab === "Timeline" ? (
492491
<ChatIcon />
@@ -509,10 +508,6 @@ export default function ProjectsPage() {
509508
<PlusIcon />
510509
)}
511510
</button>
512-
) : (
513-
<button className="absolute bottom-8 right-8 w-14 h-14 bg-[#34D399] hover:bg-emerald-500 transition-transform hover:scale-105 rounded-full flex items-center justify-center shadow-lg shadow-emerald-200 text-white z-10">
514-
<PlusIcon />
515-
</button>
516511
)}
517512

518513
{/* QUICK ENTRY MODAL */}

src/components/Kanban/KanbanBoard.tsx

Lines changed: 94 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ import {
1919
} from "@/types/project";
2020
import { UserAvatar } from "@/components/UserAvatar";
2121

22+
import { createClient } from "@/utils/supabase/client";
23+
import { useDebounce } from "@/hooks/useDebounce";
24+
2225
interface KanbanBoardProps {
2326
projectId: number;
2427
columns: Column[];
@@ -74,6 +77,21 @@ export function KanbanBoard({
7477
const [localColumns, setLocalColumns] = useState<Column[]>(columns);
7578
const [localTasks, setLocalTasks] = useState<KanbanTask[]>(tasks);
7679

80+
/* ── Search state ── */
81+
const [searchTerm, setSearchTerm] = useState("");
82+
const debouncedSearchTerm = useDebounce(searchTerm, 500);
83+
const [isSearching, setIsSearching] = useState(false);
84+
85+
// We keep isSearching for UI feedback if needed,
86+
// though client-side search is nearly instantaneous.
87+
useEffect(() => {
88+
if (searchTerm !== debouncedSearchTerm) {
89+
setIsSearching(true);
90+
} else {
91+
setIsSearching(false);
92+
}
93+
}, [searchTerm, debouncedSearchTerm]);
94+
7795
const lastSyncedColumnsRef = useRef(columns);
7896
const lastSyncedTasksRef = useRef(tasks);
7997

@@ -464,16 +482,27 @@ export function KanbanBoard({
464482
return map;
465483
}, [boardLabels]);
466484

467-
// Filter tasks by assignee and labels (client-side) using useMemo for performance
485+
// Filter tasks by assignee, labels, and search term (client-side) using useMemo for performance
468486
const { effectiveLabelIds, filteredTasks } = React.useMemo(() => {
469487
let tasks = localTasks;
470488

489+
// 1. Filter by Search Term
490+
if (debouncedSearchTerm.trim()) {
491+
const term = debouncedSearchTerm.toLowerCase();
492+
tasks = tasks.filter((t) =>
493+
t.title.toLowerCase().includes(term) ||
494+
(t.description && t.description.toLowerCase().includes(term))
495+
);
496+
}
497+
498+
// 2. Filter by Assignee
471499
if (filterUserId) {
472500
tasks = tasks.filter((t) =>
473501
t.assignees?.some((a) => a.user_id === filterUserId),
474502
);
475503
}
476504

505+
// 3. Filter by Labels
477506
let effectiveIds = filterLabelIds || [];
478507
if (filterLabelIds && filterLabelIds.length > 0) {
479508
const selectedColors = new Set(
@@ -497,7 +526,7 @@ export function KanbanBoard({
497526
}
498527

499528
return { effectiveLabelIds: effectiveIds, filteredTasks: tasks };
500-
}, [localTasks, filterUserId, filterLabelIds, boardLabels]);
529+
}, [localTasks, filterUserId, filterLabelIds, boardLabels, debouncedSearchTerm]);
501530

502531
// Don't render on server to avoid hydration mismatch
503532
if (!isMounted) {
@@ -524,16 +553,15 @@ export function KanbanBoard({
524553
);
525554
}
526555

527-
const isFiltering = !!filterUserId || effectiveLabelIds.length > 0;
556+
const isFiltering =
557+
!!filterUserId || effectiveLabelIds.length > 0 || !!debouncedSearchTerm.trim();
528558

529559
return (
530560
<div className="flex flex-col h-full">
531-
{/* ── Assignee & Label Filters Bar ── */}
532-
{(boardMembers.length > 0 ||
533-
boardLabels.length > 0 ||
534-
!!filterUserId ||
535-
filterLabelIds.length > 0) && (
536-
<div className="flex items-center gap-2 px-1 pb-3 flex-wrap">
561+
{/* ── Filter & Search Bar ── */}
562+
<div className="flex items-center justify-between gap-4 px-1 pb-3 flex-wrap">
563+
{/* Left: Assignee & Label Filters */}
564+
<div className="flex items-center gap-2 flex-wrap">
537565
{boardMembers.length > 0 && (
538566
<>
539567
{/* My Tasks button */}
@@ -543,11 +571,10 @@ export function KanbanBoard({
543571
filterUserId === currentUserId ? null : currentUserId,
544572
)
545573
}
546-
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-bold border-2 transition-all ${
547-
filterUserId === currentUserId
574+
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-bold border-2 transition-all ${filterUserId === currentUserId
548575
? "bg-[#28B8FA] border-[#28B8FA] text-white shadow-md shadow-cyan-200"
549576
: "bg-white border-slate-200 text-slate-500 hover:border-[#28B8FA] hover:text-[#28B8FA]"
550-
}`}
577+
}`}
551578
title="Chỉ hiện nhiệm vụ của tôi"
552579
>
553580
<svg
@@ -579,11 +606,10 @@ export function KanbanBoard({
579606
onFilterChange?.(isActive ? null : member.user_id)
580607
}
581608
title={`Lọc theo: ${member.display_name}`}
582-
className={`relative rounded-full transition-all focus:outline-none ${
583-
isActive
609+
className={`relative rounded-full transition-all focus:outline-none ${isActive
584610
? "ring-3 ring-[#28B8FA] ring-offset-2 scale-110"
585611
: "ring-2 ring-transparent hover:ring-[#28B8FA]/40 hover:ring-offset-1 hover:scale-105"
586-
}`}
612+
}`}
587613
>
588614
<UserAvatar
589615
avatarUrl={member.avatar_url}
@@ -627,11 +653,10 @@ export function KanbanBoard({
627653
<div className="relative" ref={labelFilterPopoverRef}>
628654
<button
629655
onClick={() => setShowLabelFilterPopover((v) => !v)}
630-
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-bold border-2 transition-all ${
631-
filterLabelIds.length > 0 || showLabelFilterPopover
656+
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-bold border-2 transition-all ${filterLabelIds.length > 0 || showLabelFilterPopover
632657
? "bg-[#28B8FA] border-[#28B8FA] text-white shadow-md shadow-cyan-200"
633658
: "bg-white border-slate-200 text-slate-500 hover:border-[#28B8FA] hover:text-[#28B8FA]"
634-
}`}
659+
}`}
635660
title="Lọc theo nhãn"
636661
>
637662
<svg
@@ -670,8 +695,8 @@ export function KanbanBoard({
670695
// Find all IDs with this same color
671696
const relatedIds = label.color_hex
672697
? colorToLabelIdsMap.get(label.color_hex) || [
673-
label.id,
674-
]
698+
label.id,
699+
]
675700
: [label.id];
676701

677702
if (isActive) {
@@ -755,7 +780,54 @@ export function KanbanBoard({
755780
</button>
756781
)}
757782
</div>
758-
)}
783+
784+
{/* Right: Search Input */}
785+
<div className="relative">
786+
<input
787+
type="text"
788+
placeholder="Tìm kiếm thẻ..."
789+
value={searchTerm}
790+
onChange={(e) => setSearchTerm(e.target.value)}
791+
className="pl-9 pr-8 py-1.5 rounded-full border border-slate-200 text-sm focus:outline-none focus:border-[#28B8FA] focus:ring-1 focus:ring-[#28B8FA] w-64 shadow-sm transition-all bg-white text-slate-700"
792+
/>
793+
<svg
794+
className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 w-4 h-4"
795+
fill="none"
796+
stroke="currentColor"
797+
viewBox="0 0 24 24"
798+
xmlns="http://www.w3.org/2000/svg"
799+
>
800+
<path
801+
strokeLinecap="round"
802+
strokeLinejoin="round"
803+
strokeWidth={2.5}
804+
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
805+
/>
806+
</svg>
807+
{isSearching && (
808+
<div className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 border-2 border-slate-200 border-t-[#28B8FA] rounded-full animate-spin"></div>
809+
)}
810+
{searchTerm && !isSearching && (
811+
<button
812+
onClick={() => setSearchTerm("")}
813+
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-full bg-slate-200 text-slate-500 hover:bg-slate-300 p-0.5 transition-colors"
814+
>
815+
<svg
816+
width="12"
817+
height="12"
818+
viewBox="0 0 24 24"
819+
fill="none"
820+
stroke="currentColor"
821+
strokeWidth="3"
822+
strokeLinecap="round"
823+
>
824+
<line x1="18" y1="6" x2="6" y2="18" />
825+
<line x1="6" y1="6" x2="18" y2="18" />
826+
</svg>
827+
</button>
828+
)}
829+
</div>
830+
</div>
759831

760832
<DragDropContext onDragEnd={onDragEnd}>
761833
<Droppable
@@ -852,4 +924,4 @@ export function KanbanBoard({
852924
</DragDropContext>
853925
</div>
854926
);
855-
}
927+
}

src/components/Kanban/KanbanColumn.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ interface KanbanColumnProps {
2828
onAddLabel?: (taskId: number, labelId: number) => Promise<void>;
2929
onRemoveLabel?: (taskId: number, labelId: number) => Promise<void>;
3030
isDragDisabled?: boolean;
31+
searchMatchedTaskIds?: Set<number> | null;
3132
}
3233

3334
// All styles are visually distinct (no "plain/colorless" entry).
@@ -83,6 +84,7 @@ export function KanbanColumn({
8384
onAddLabel,
8485
onRemoveLabel,
8586
isDragDisabled = false,
87+
searchMatchedTaskIds,
8688
}: KanbanColumnProps) {
8789
// Prime multiplier spreads consecutive IDs across different colors.
8890
// e.g. id=10→4, id=11→1, id=12→4... wait, let's verify:
@@ -223,7 +225,10 @@ export function KanbanColumn({
223225
{(
224226
droppableProvided: DroppableProvided,
225227
droppableSnapshot: DroppableStateSnapshot,
226-
) => (
228+
) => {
229+
const visibleTasks = tasks;
230+
231+
return (
227232
<div
228233
ref={droppableProvided.innerRef}
229234
{...droppableProvided.droppableProps}
@@ -232,12 +237,12 @@ export function KanbanColumn({
232237
: ""
233238
}`}
234239
>
235-
{tasks.length === 0 && !droppableSnapshot.isDraggingOver && (
240+
{visibleTasks.length === 0 && !droppableSnapshot.isDraggingOver && (
236241
<div className="bg-white p-5 rounded-2xl border border-dashed border-slate-200 text-sm text-slate-400">
237242
Chưa có nhiệm vụ
238243
</div>
239244
)}
240-
{tasks.map((task, taskIndex) => (
245+
{visibleTasks.map((task, taskIndex) => (
241246
<KanbanTask
242247
key={task.id}
243248
id={task.id}
@@ -259,6 +264,7 @@ export function KanbanColumn({
259264
{droppableProvided.placeholder}
260265
</div>
261266
)}
267+
}
262268
</Droppable>
263269
<button
264270
onClick={() => onAddTask(column.id)}

src/hooks/useDebounce.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { useState, useEffect } from "react";
2+
3+
export function useDebounce<T>(value: T, delay: number = 500): T {
4+
const [debouncedValue, setDebouncedValue] = useState<T>(value);
5+
6+
useEffect(() => {
7+
const timer = setTimeout(() => {
8+
setDebouncedValue(value);
9+
}, delay);
10+
11+
return () => {
12+
clearTimeout(timer);
13+
};
14+
}, [value, delay]);
15+
16+
return debouncedValue;
17+
}

0 commit comments

Comments
 (0)