@@ -19,6 +19,9 @@ import {
1919} from "@/types/project" ;
2020import { UserAvatar } from "@/components/UserAvatar" ;
2121
22+ import { createClient } from "@/utils/supabase/client" ;
23+ import { useDebounce } from "@/hooks/useDebounce" ;
24+
2225interface 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+ }
0 commit comments