@@ -2,7 +2,10 @@ import { Select, ValidatedForm } from "@carbon/form";
22import {
33 Badge ,
44 Button ,
5+ cn ,
6+ Count ,
57 HStack ,
8+ Input ,
69 ModalDrawer ,
710 ModalDrawerBody ,
811 ModalDrawerContent ,
@@ -14,20 +17,21 @@ import {
1417 TabsContent ,
1518 TabsList ,
1619 TabsTrigger ,
20+ ToggleGroup ,
21+ ToggleGroupItem ,
1722 VStack ,
1823} from "@carbon/react" ;
1924import { useFetcher } from "@remix-run/react" ;
20- import { memo , useState } from "react" ;
21- import type { ColumnDef } from "@tanstack/react-table" ;
25+ import { memo , useMemo , useState } from "react" ;
2226import {
2327 LuTriangleAlert ,
2428 LuCalendar ,
2529 LuCircleCheck ,
2630 LuClock ,
27- LuUser ,
31+ LuSearch ,
2832} from "react-icons/lu" ;
2933import type { z } from "zod/v3" ;
30- import { EmployeeAvatar , Table } from "~/components" ;
34+ import { EmployeeAvatar , Empty } from "~/components" ;
3135import { Hidden , Submit , Users } from "~/components/Form" ;
3236import { usePermissions } from "~/hooks" ;
3337import { trainingAssignmentValidator } from "~/modules/people" ;
@@ -76,110 +80,217 @@ function StatusBadge({ status }: { status: string }) {
7680 }
7781}
7882
79- const StatusTable = memo (
80- ( {
81- data,
82- currentPeriod,
83- } : {
84- data : TrainingAssignmentStatusItem [ ] ;
85- currentPeriod : string | null ;
86- } ) => {
87- const permissions = usePermissions ( ) ;
88- const fetcher = useFetcher ( ) ;
83+ type StatusFilter =
84+ | "All"
85+ | "Completed"
86+ | "Pending"
87+ | "Overdue"
88+ | "Not Required" ;
89+
90+ function AssignmentListItem ( {
91+ assignment,
92+ currentPeriod,
93+ disabled,
94+ isLast,
95+ } : {
96+ assignment : TrainingAssignmentStatusItem ;
97+ currentPeriod : string | null ;
98+ disabled : boolean ;
99+ isLast : boolean ;
100+ } ) {
101+ const fetcher = useFetcher ( ) ;
102+ const isSubmitting = fetcher . state !== "idle" ;
103+ const canMarkComplete =
104+ assignment . status !== "Completed" && assignment . status !== "Not Required" ;
89105
90- const columns : ColumnDef < TrainingAssignmentStatusItem > [ ] = [
91- {
92- accessorKey : "employeeName" ,
93- header : "Employee" ,
94- cell : ( { row } ) => (
95- < HStack spacing = { 2 } >
96- < EmployeeAvatar employeeId = { row . original . employeeId } />
97- < span > { row . original . employeeName } </ span >
98- </ HStack >
99- ) ,
100- meta : {
101- icon : < LuUser /> ,
102- } ,
103- } ,
104- {
105- accessorKey : "employeeStartDate" ,
106- header : "Start Date" ,
107- cell : ( { row } ) =>
108- row . original . employeeStartDate
109- ? new Date ( row . original . employeeStartDate ) . toLocaleDateString ( )
110- : "-" ,
111- meta : {
112- icon : < LuCalendar /> ,
113- } ,
114- } ,
115- {
116- accessorKey : "status" ,
117- header : "Status" ,
118- cell : ( { row } ) => < StatusBadge status = { row . original . status } /> ,
119- } ,
120- {
121- accessorKey : "completedAt" ,
122- header : "Completed At" ,
123- cell : ( { row } ) =>
124- row . original . completedAt
125- ? new Date ( row . original . completedAt ) . toLocaleDateString ( )
126- : "-" ,
127- meta : {
128- icon : < LuClock /> ,
129- } ,
130- } ,
131- {
132- id : "actions" ,
133- header : "" ,
134- cell : ( { row } ) => {
135- if (
136- row . original . status === "Completed" ||
137- row . original . status === "Not Required"
138- ) {
139- return null ;
140- }
141- const isSubmitting = fetcher . state !== "idle" ;
142- return (
106+ return (
107+ < div className = { cn ( "p-4" , ! isLast && "border-b w-full" ) } >
108+ < div className = "flex flex-1 justify-between items-center w-full" >
109+ < HStack spacing = { 4 } className = "flex-1" >
110+ < VStack spacing = { 0 } className = "flex-1" >
111+ < EmployeeAvatar employeeId = { assignment . employeeId } />
112+ { assignment . employeeStartDate && (
113+ < HStack spacing = { 1 } className = "text-xs text-muted-foreground" >
114+ < LuCalendar className = "size-3" />
115+ < span >
116+ Started{ " " }
117+ { new Date ( assignment . employeeStartDate ) . toLocaleDateString ( ) }
118+ </ span >
119+ </ HStack >
120+ ) }
121+ </ VStack >
122+ </ HStack >
123+ < HStack spacing = { 4 } >
124+ < StatusBadge status = { assignment . status } />
125+ { assignment . completedAt && (
126+ < span className = "text-xs text-muted-foreground" >
127+ < LuClock className = "inline mr-1 size-3" />
128+ { new Date ( assignment . completedAt ) . toLocaleDateString ( ) }
129+ </ span >
130+ ) }
131+ { canMarkComplete && (
143132 < fetcher . Form method = "post" action = { path . to . markTrainingComplete } >
144133 < input
145134 type = "hidden"
146135 name = "trainingAssignmentId"
147- value = { row . original . trainingAssignmentId }
136+ value = { assignment . trainingAssignmentId }
148137 />
149138 < input
150139 type = "hidden"
151140 name = "employeeId"
152- value = { row . original . employeeId }
141+ value = { assignment . employeeId }
153142 />
154143 < input type = "hidden" name = "period" value = { currentPeriod ?? "" } />
155144 < Button
156145 type = "submit"
157146 variant = "secondary"
158147 size = "sm"
159- leftIcon = { < LuCircleCheck /> }
160- disabled = { ! permissions . can ( "update" , "people" ) || isSubmitting }
148+ disabled = { disabled || isSubmitting }
161149 isLoading = { isSubmitting }
150+ leftIcon = { < LuCircleCheck /> }
162151 >
163152 Mark Complete
164153 </ Button >
165154 </ fetcher . Form >
166- ) ;
167- } ,
168- } ,
169- ] ;
155+ ) }
156+ </ HStack >
157+ </ div >
158+ </ div >
159+ ) ;
160+ }
161+
162+ const StatusList = memo (
163+ ( {
164+ data,
165+ currentPeriod,
166+ } : {
167+ data : TrainingAssignmentStatusItem [ ] ;
168+ currentPeriod : string | null ;
169+ } ) => {
170+ const permissions = usePermissions ( ) ;
171+ const [ search , setSearch ] = useState ( "" ) ;
172+ const [ statusFilter , setStatusFilter ] = useState < StatusFilter > ( "All" ) ;
173+
174+ const filteredAssignments = useMemo ( ( ) => {
175+ return data . filter ( ( assignment ) => {
176+ const matchesSearch =
177+ ( search === "" ||
178+ assignment . employeeName
179+ ?. toLowerCase ( )
180+ . includes ( search . toLowerCase ( ) ) ) ??
181+ false ;
182+ const matchesStatus =
183+ statusFilter === "All" || assignment . status === statusFilter ;
184+ return matchesSearch && matchesStatus ;
185+ } ) ;
186+ } , [ data , search , statusFilter ] ) ;
187+
188+ const statusCounts = useMemo ( ( ) => {
189+ return data . reduce ( ( acc , assignment ) => {
190+ acc [ assignment . status ] = ( acc [ assignment . status ] || 0 ) + 1 ;
191+ return acc ;
192+ } , { } as Record < string , number > ) ;
193+ } , [ data ] ) ;
170194
171195 return (
172- < Table < TrainingAssignmentStatusItem >
173- data = { data }
174- columns = { columns }
175- count = { data . length }
176- withPagination = { false }
177- />
196+ < VStack spacing = { 0 } className = "h-full w-full" >
197+ < div className = "flex flex-col gap-4 w-full" >
198+ < div className = "relative" >
199+ < LuSearch className = "absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
200+ < Input
201+ placeholder = "Search employees..."
202+ value = { search }
203+ onChange = { ( e ) => setSearch ( e . target . value ) }
204+ className = "pl-9"
205+ />
206+ </ div >
207+ < ToggleGroup
208+ type = "single"
209+ value = { statusFilter }
210+ onValueChange = { ( value ) => {
211+ if ( value ) setStatusFilter ( value as StatusFilter ) ;
212+ } }
213+ className = "justify-start flex-wrap"
214+ >
215+ < ToggleGroupItem
216+ className = "flex gap-1.5 items-center"
217+ size = "sm"
218+ value = "All"
219+ >
220+ All < Count count = { data . length } />
221+ </ ToggleGroupItem >
222+ < ToggleGroupItem
223+ className = "flex gap-1.5 items-center"
224+ size = "sm"
225+ value = "Completed"
226+ >
227+ < LuCircleCheck className = "mr-1 size-3" />
228+ Completed < Count count = { statusCounts [ "Completed" ] || 0 } />
229+ </ ToggleGroupItem >
230+ < ToggleGroupItem
231+ className = "flex gap-1.5 items-center"
232+ size = "sm"
233+ value = "Pending"
234+ >
235+ < LuClock className = "mr-1 size-3" />
236+ Pending < Count count = { statusCounts [ "Pending" ] || 0 } />
237+ </ ToggleGroupItem >
238+ < ToggleGroupItem
239+ className = "flex gap-1.5 items-center"
240+ size = "sm"
241+ value = "Overdue"
242+ >
243+ < LuTriangleAlert className = "mr-1 size-3" />
244+ Overdue < Count count = { statusCounts [ "Overdue" ] || 0 } />
245+ </ ToggleGroupItem >
246+ < ToggleGroupItem
247+ className = "flex gap-1.5 items-center"
248+ size = "sm"
249+ value = "Not Required"
250+ >
251+ Not Required < Count count = { statusCounts [ "Not Required" ] || 0 } />
252+ </ ToggleGroupItem >
253+ </ ToggleGroup >
254+ </ div >
255+ < div className = "flex-1 overflow-y-auto w-full pt-4" >
256+ { filteredAssignments . length > 0 ? (
257+ < div className = "border rounded-lg w-full" >
258+ { filteredAssignments . map ( ( assignment , index ) => (
259+ < AssignmentListItem
260+ key = { `${ assignment . employeeId } -${ assignment . trainingAssignmentId } ` }
261+ assignment = { assignment }
262+ currentPeriod = { currentPeriod }
263+ disabled = { ! permissions . can ( "update" , "people" ) }
264+ isLast = { index === filteredAssignments . length - 1 }
265+ />
266+ ) ) }
267+ </ div >
268+ ) : (
269+ < div className = "flex items-center justify-center h-full text-muted-foreground p-8" >
270+ < VStack
271+ spacing = { 2 }
272+ className = "w-full items-center justify-center"
273+ >
274+ < Empty > No employees found</ Empty >
275+ { search && (
276+ < Button
277+ variant = "ghost"
278+ size = "sm"
279+ onClick = { ( ) => setSearch ( "" ) }
280+ >
281+ Clear search
282+ </ Button >
283+ ) }
284+ </ VStack >
285+ </ div >
286+ ) }
287+ </ div >
288+ </ VStack >
178289 ) ;
179290 }
180291) ;
181292
182- StatusTable . displayName = "StatusTable " ;
293+ StatusList . displayName = "StatusList " ;
183294
184295const TrainingAssignmentForm = ( {
185296 initialValues,
@@ -199,6 +310,9 @@ const TrainingAssignmentForm = ({
199310
200311 const [ activeTab , setActiveTab ] = useState < string > ( "details" ) ;
201312
313+ // Drawer grows when status tab is visible
314+ const drawerSize = activeTab === "status" ? "lg" : undefined ;
315+
202316 return (
203317 < ModalDrawerProvider type = "drawer" >
204318 < ModalDrawer
@@ -207,7 +321,7 @@ const TrainingAssignmentForm = ({
207321 if ( ! open ) onClose ?.( ) ;
208322 } }
209323 >
210- < ModalDrawerContent >
324+ < ModalDrawerContent size = { drawerSize } >
211325 < Tabs
212326 value = { activeTab }
213327 onValueChange = { setActiveTab }
@@ -259,7 +373,7 @@ const TrainingAssignmentForm = ({
259373 className = "w-full flex flex-col gap-4"
260374 >
261375 { assignmentStatus . length > 0 ? (
262- < StatusTable
376+ < StatusList
263377 data = { assignmentStatus }
264378 currentPeriod = { currentPeriod }
265379 />
0 commit comments