PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.2.8
Yatra – Travel Booking & Tour Operator Software v3.0.2.8
3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 2.0.10 All 83 releases
yatra / resources / js / components / shared / Table.tsx

Table.tsx in Yatra – Travel Booking & Tour Operator Software 3.0.2.8, at resources/js/components/shared/Table.tsx

779 lines 27.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import React, { useMemo, useState } from "react";
2 import {
3 Table as UITable,
4 TableBody,
5 TableCell,
6 TableHead,
7 TableHeader,
8 TableRow,
9 } from "../ui/table";
10 import { Button } from "../ui/button";
11 import {
12 MoreVertical,
13 ChevronRight,
14 ChevronDown,
15 Copy,
16 Check,
17 } from "lucide-react";
18 import { __ } from "../../lib/i18n";
19 import { ConditionalRender } from "../ui/conditional-render";
20 import { ErrorRequestInfo } from "../../lib/errors";
21
22 interface TableColumn {
23 key: string;
24 label: string;
25 sortable?: boolean;
26 visible?: boolean;
27 width?: string;
28 render?: (item: any, index: number) => React.ReactNode;
29 }
30
31 interface TableAction {
32 key: string;
33 label: string;
34 icon: React.ReactNode;
35 onClick: (item: any) => void;
36 condition?: (item: any) => boolean;
37 variant?: "default" | "destructive" | "outline";
38 }
39
40 interface TableProps {
41 data: any[];
42 columns: TableColumn[];
43 actions?: TableAction[];
44 isLoading?: boolean;
45 isError?: boolean;
46 errorText?: string;
47 emptyText?: string;
48 emptyDescription?: string;
49 onCreateClick?: () => void;
50 onSort?: (field: string) => void;
51 getSortIcon?: (field: string) => React.ReactNode;
52 selectedItemIds?: (string | number)[];
53 onSelectItem?: (id: string | number, checked: boolean) => void;
54 onSelectAll?: (checked: boolean) => void;
55 isAllSelected?: boolean;
56 getItemId?: (item: any) => string | number;
57 getItemStatus?: (item: any) => string;
58 statusFilter?: string;
59 capability?: string;
60 skeletonRows?: number;
61 errorDescription?: string;
62 onRetry?: () => void;
63 errorDetails?: string;
64 errorRequestInfo?: ErrorRequestInfo;
65 // Hierarchical support
66 isHierarchical?: boolean;
67 expandedIds?: Set<string | number>;
68 onToggleExpand?: (id: string | number) => void;
69 getChildren?: (item: any) => any[];
70 renderRowContent?: (
71 item: any,
72 index: number,
73 isChild?: boolean,
74 ) => React.ReactNode[];
75 }
76
77 export const Table: React.FC<TableProps> = ({
78 data,
79 columns,
80 actions = [],
81 isLoading = false,
82 isError = false,
83 errorText = __("Error loading data", "yatra"),
84 emptyText = __("No items found", "yatra"),
85 emptyDescription = __("Get started by creating your first item.", "yatra"),
86 onCreateClick,
87 onSort,
88 getSortIcon,
89 selectedItemIds = [],
90 onSelectItem,
91 onSelectAll,
92 isAllSelected = false,
93 getItemId = (item) => item.id,
94 getItemStatus = (item) => item.status,
95 statusFilter = "",
96 capability = "yatra_view_trips",
97 skeletonRows = 5,
98 errorDescription = __(
99 "Something went wrong while loading this data. Please try again in a moment.",
100 "yatra",
101 ),
102 onRetry,
103 errorDetails,
104 errorRequestInfo,
105 // Hierarchical props
106 isHierarchical = false,
107 expandedIds = new Set(),
108 onToggleExpand,
109 getChildren,
110 renderRowContent,
111 }) => {
112 const [openDropdownId, setOpenDropdownId] = useState<string | number | null>(
113 null,
114 );
115 const [dropdownPosition, setDropdownPosition] = useState<{
116 top: number;
117 right: number;
118 } | null>(null);
119 const [copied, setCopied] = useState(false);
120
121 const handleRetry = () => {
122 if (onRetry) {
123 onRetry();
124 return;
125 }
126 if (typeof window !== "undefined") {
127 window.location.reload();
128 }
129 };
130
131 // Close dropdown when clicking outside
132 React.useEffect(() => {
133 const handleClickOutside = (event: MouseEvent) => {
134 const target = event.target as HTMLElement;
135 if (
136 target.closest("[data-dropdown-trigger]") ||
137 target.closest("[data-dropdown-content]")
138 ) {
139 return;
140 }
141 setOpenDropdownId(null);
142 };
143
144 if (openDropdownId !== null) {
145 document.addEventListener("click", handleClickOutside);
146 return () => document.removeEventListener("click", handleClickOutside);
147 }
148 }, [openDropdownId]);
149
150 // Render skeleton loading state
151 const renderSkeleton = () => (
152 <UITable>
153 <TableHeader>
154 <TableRow>
155 {isHierarchical && <TableHead className="w-12"></TableHead>}
156 {onSelectItem && onSelectAll && (
157 <TableHead className="w-12"></TableHead>
158 )}
159 {columns
160 .filter((col) => col.visible !== false)
161 .map((column) => (
162 <TableHead key={column.key} className={column.width}>
163 {column.label}
164 </TableHead>
165 ))}
166 {actions.length > 0 && (
167 <TableHead className="text-right w-[100px]">
168 {__("Actions", "yatra")}
169 </TableHead>
170 )}
171 </TableRow>
172 </TableHeader>
173 <TableBody>
174 {[...Array(skeletonRows)].map((_, index) => (
175 <TableRow key={`skeleton-${index}`}>
176 {isHierarchical && (
177 <TableCell>
178 <div className="w-4 h-4 bg-gray-100 dark:bg-gray-800 rounded animate-pulse" />
179 </TableCell>
180 )}
181 {onSelectItem && onSelectAll && (
182 <TableCell>
183 <div className="w-4 h-4 bg-gray-100 dark:bg-gray-800 rounded animate-pulse" />
184 </TableCell>
185 )}
186 {columns
187 .filter((col) => col.visible !== false)
188 .map((column) => (
189 <TableCell key={`${column.key}-${index}`}>
190 <div className="h-4 bg-gray-100 dark:bg-gray-800 rounded animate-pulse" />
191 </TableCell>
192 ))}
193 {actions.length > 0 && (
194 <TableCell className="text-right">
195 <div className="flex items-center justify-end gap-2">
196 <div className="h-4 w-4 bg-gray-100 dark:bg-gray-800 rounded animate-pulse" />
197 </div>
198 </TableCell>
199 )}
200 </TableRow>
201 ))}
202 </TableBody>
203 </UITable>
204 );
205
206 // Render error state
207 const composedErrorText = useMemo(() => {
208 const parts: string[] = [];
209 if (errorRequestInfo) {
210 if (errorRequestInfo.method) {
211 parts.push(`Request Method: ${errorRequestInfo.method}`);
212 }
213 if (errorRequestInfo.url) {
214 parts.push(`Request URL: ${errorRequestInfo.url}`);
215 }
216 if (errorRequestInfo.payload) {
217 parts.push(`Request Payload:\n${errorRequestInfo.payload}`);
218 }
219 }
220 if (errorDetails) {
221 parts.push(`Response:\n${errorDetails}`);
222 }
223 return parts.join("\n\n").trim();
224 }, [errorRequestInfo, errorDetails]);
225
226 const renderError = () => (
227 <div className="relative flex flex-col items-center justify-center text-center py-14 px-6 my-4 min-h-[360px]">
228 <div className="absolute inset-4 rounded-2xl border-2 border-dashed border-red-200/60 dark:border-red-900/50 bg-gradient-to-br from-red-50 via-orange-50/60 to-white dark:from-red-900/40 dark:via-orange-900/10 dark:to-gray-900 shadow-sm" />
229 <div className="relative z-10 max-w-lg mx-auto space-y-6">
230 <div className="inline-flex items-center justify-center w-24 h-24 rounded-2xl bg-gradient-to-br from-red-100 via-rose-50 to-orange-100 dark:from-red-900/50 dark:via-rose-800/30 dark:to-orange-900/40 ring-8 ring-red-100/70 dark:ring-red-900/30 shadow-lg">
231 <svg
232 className="w-12 h-12 text-red-500 dark:text-red-300"
233 viewBox="0 0 24 24"
234 fill="none"
235 stroke="currentColor"
236 strokeWidth="1.5"
237 >
238 <path
239 strokeLinecap="round"
240 strokeLinejoin="round"
241 d="M12 9v4m0 4h.01M10.29 3.86L2.82 17a2 2 0 001.71 3h14.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
242 />
243 </svg>
244 </div>
245
246 <div className="space-y-3">
247 <h3 className="text-2xl font-bold text-gray-900 dark:text-white">
248 {errorText}
249 </h3>
250 <p className="text-base text-gray-600 dark:text-gray-400 leading-relaxed">
251 {errorDescription}
252 </p>
253 </div>
254
255 <div className="flex flex-col sm:flex-row items-center justify-center gap-3">
256 <Button onClick={handleRetry} className="w-full sm:w-auto px-6">
257 {__("Try again", "yatra")}
258 </Button>
259 <Button
260 variant="outline"
261 onClick={() => {
262 if (typeof window !== "undefined") {
263 window.open(
264 "https://wordpress.org/support/plugin/yatra",
265 "_blank",
266 );
267 }
268 }}
269 className="w-full sm:w-auto px-6 text-gray-700 dark:text-gray-200 border-gray-200 dark:border-gray-700"
270 >
271 {__("Visit support center", "yatra")}
272 </Button>
273 </div>
274
275 {(errorDetails || errorRequestInfo) && (
276 <div className="relative w-full text-left rounded-2xl border border-red-100/80 dark:border-red-900/40 bg-white/90 dark:bg-gray-900/70 shadow-inner space-y-0">
277 <div className="flex items-center justify-between px-4 py-3 border-b border-red-50 dark:border-red-900/30">
278 <p className="text-sm font-medium text-gray-700 dark:text-gray-300">
279 {__("Technical details", "yatra")}
280 </p>
281 <Button
282 type="button"
283 size="sm"
284 variant="ghost"
285 className="flex items-center gap-2 text-gray-600 dark:text-gray-300 hover:text-gray-900"
286 onClick={() => {
287 if (!navigator?.clipboard || !composedErrorText) {
288 return;
289 }
290 navigator.clipboard
291 .writeText(composedErrorText)
292 .then(() => {
293 setCopied(true);
294 setTimeout(() => setCopied(false), 2000);
295 })
296 .catch(() => {});
297 }}
298 >
299 {copied ? (
300 <>
301 <Check className="w-4 h-4" />
302 {__("Copied", "yatra")}
303 </>
304 ) : (
305 <>
306 <Copy className="w-4 h-4" />
307 {__("Copy details", "yatra")}
308 </>
309 )}
310 </Button>
311 </div>
312 {errorRequestInfo && (
313 <div className="px-4 py-3 border-b border-red-50 dark:border-red-900/20 space-y-2 text-sm text-left text-gray-700 dark:text-gray-200">
314 {errorRequestInfo.method && (
315 <div>
316 <span className="font-medium">
317 {__("Method:", "yatra")}
318 </span>{" "}
319 <span className="font-mono">{errorRequestInfo.method}</span>
320 </div>
321 )}
322 {errorRequestInfo.url && (
323 <div className="break-all">
324 <span className="font-medium">{__("URL:", "yatra")}</span>{" "}
325 <span className="font-mono">{errorRequestInfo.url}</span>
326 </div>
327 )}
328 {errorRequestInfo.payload && (
329 <div>
330 <span className="font-medium block mb-1">
331 {__("Payload:", "yatra")}
332 </span>
333 <pre className="max-h-40 overflow-auto px-3 py-2 rounded bg-red-50/60 dark:bg-red-900/30 text-xs font-mono text-gray-800 dark:text-gray-100 whitespace-pre-wrap">
334 {errorRequestInfo.payload}
335 </pre>
336 </div>
337 )}
338 </div>
339 )}
340 {errorDetails && (
341 <pre className="max-h-56 overflow-auto px-4 py-3 text-xs leading-relaxed font-mono text-gray-700 dark:text-gray-200 whitespace-pre-wrap">
342 {errorDetails}
343 </pre>
344 )}
345 </div>
346 )}
347 </div>
348 </div>
349 );
350
351 // Render empty state
352 const renderEmpty = () => (
353 <div className="relative flex flex-col items-center justify-center text-center py-12 px-6 my-4 min-h-[350px]">
354 {/* Background decoration */}
355 <div className="absolute inset-4 bg-gradient-to-br from-gray-50/80 via-blue-50/30 to-white dark:from-gray-900/80 dark:via-blue-900/20 dark:to-gray-800/80 rounded-2xl border-2 border-dashed border-gray-300/50 dark:border-gray-600/50 shadow-sm"></div>
356
357 {/* Content */}
358 <div className="relative z-10 max-w-lg mx-auto space-y-8">
359 {/* Icon */}
360 <div className="inline-flex items-center justify-center w-24 h-24 rounded-2xl bg-gradient-to-br from-blue-100 via-blue-50 to-indigo-100 dark:from-blue-900/40 dark:via-blue-800/20 dark:to-indigo-900/40 ring-8 ring-blue-100/60 dark:ring-blue-900/30 shadow-lg">
361 <svg
362 className="w-12 h-12 text-blue-600 dark:text-blue-400"
363 fill="none"
364 stroke="currentColor"
365 viewBox="0 0 24 24"
366 strokeWidth="1.5"
367 >
368 <path
369 strokeLinecap="round"
370 strokeLinejoin="round"
371 d="M7 7h10v10H7zM5 3h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2z"
372 />
373 <path
374 strokeLinecap="round"
375 strokeLinejoin="round"
376 d="M9 9h6M9 12h6M9 15h4"
377 />
378 </svg>
379 </div>
380
381 {/* Text content */}
382 <div className="space-y-4">
383 <h3 className="text-2xl font-bold text-gray-900 dark:text-white">
384 {emptyText}
385 </h3>
386 <p className="text-base text-gray-600 dark:text-gray-400 leading-relaxed max-w-md mx-auto">
387 {emptyDescription}
388 </p>
389 </div>
390
391 {/* Action button */}
392 {onCreateClick && (
393 <div className="pt-4">
394 <Button
395 onClick={onCreateClick}
396 className="inline-flex items-center gap-3 px-8 py-3 bg-blue-600 hover:bg-blue-700 text-white text-base font-semibold rounded-xl shadow-lg hover:shadow-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 transform hover:scale-[1.02]"
397 >
398 <svg
399 className="w-4 h-4"
400 fill="none"
401 stroke="currentColor"
402 viewBox="0 0 24 24"
403 strokeWidth="2"
404 >
405 <path
406 strokeLinecap="round"
407 strokeLinejoin="round"
408 d="M12 4v16m8-8H4"
409 />
410 </svg>
411 {__("Create New", "yatra")}
412 </Button>
413 </div>
414 )}
415 </div>
416 </div>
417 );
418
419 // Render hierarchical row with children
420 const renderHierarchicalRow = (
421 item: any,
422 index: number,
423 isChild = false,
424 ): React.ReactNode => {
425 const itemId = getItemId(item);
426 const itemStatus = getItemStatus(item);
427 const isTrash = itemStatus === "trash" || statusFilter === "trash";
428 const children = getChildren ? getChildren(item) : [];
429 const hasChildren = children.length > 0;
430 const isExpanded = expandedIds.has(itemId);
431
432 return (
433 <React.Fragment key={itemId}>
434 <TableRow
435 className={`${isTrash ? "bg-red-50/30 dark:bg-red-900/10 opacity-75 hover:bg-red-50/50 dark:hover:bg-red-900/20" : ""} ${isChild ? "bg-gray-50 dark:bg-gray-900/50" : ""}`}
436 >
437 {/* Expand/collapse column */}
438 <TableCell className="w-12">
439 {hasChildren && onToggleExpand && (
440 <button
441 onClick={() => onToggleExpand(itemId)}
442 className="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
443 >
444 {isExpanded ? (
445 <ChevronDown className="w-4 h-4" />
446 ) : (
447 <ChevronRight className="w-4 h-4" />
448 )}
449 </button>
450 )}
451 </TableCell>
452
453 {/* Selection checkbox */}
454 {onSelectItem && (
455 <TableCell>
456 <input
457 type="checkbox"
458 className="rounded border-gray-300 dark:border-gray-600"
459 checked={selectedItemIds.includes(itemId)}
460 onChange={(e) => onSelectItem(itemId, e.target.checked)}
461 aria-label={__("Select item", "yatra")}
462 />
463 </TableCell>
464 )}
465
466 {/* Custom row content or default columns */}
467 {renderRowContent
468 ? renderRowContent(item, index, isChild).map(
469 (cellContent, cellIndex) => (
470 <TableCell
471 key={`cell-${itemId}-${cellIndex}`}
472 className={
473 isTrash ? "text-gray-400 dark:text-gray-600" : ""
474 }
475 >
476 {cellContent}
477 </TableCell>
478 ),
479 )
480 : columns
481 .filter((col) => col.visible !== false)
482 .map((column) => (
483 <TableCell
484 key={`${column.key}-${itemId}`}
485 className={
486 isTrash ? "text-gray-400 dark:text-gray-600" : ""
487 }
488 >
489 {column.render
490 ? column.render(item, index)
491 : item[column.key]}
492 </TableCell>
493 ))}
494
495 {/* Actions */}
496 {actions.length > 0 && (
497 <TableCell className="text-right">
498 <div className="relative inline-block">
499 <Button
500 variant="ghost"
501 size="icon"
502 onClick={(e) => {
503 e.preventDefault();
504 e.stopPropagation();
505 const button = e.currentTarget;
506 const rect = button.getBoundingClientRect();
507
508 // Calculate dropdown height (estimate based on number of actions)
509 const dropdownHeight =
510 actions.filter((a) => !a.condition || a.condition(item))
511 .length *
512 40 +
513 16;
514 const spaceBelow = window.innerHeight - rect.bottom;
515 const spaceAbove = rect.top;
516
517 // Position dropdown above if not enough space below
518 const shouldPositionAbove =
519 spaceBelow < dropdownHeight &&
520 spaceAbove > dropdownHeight;
521
522 setDropdownPosition({
523 top: shouldPositionAbove
524 ? rect.top + window.scrollY - dropdownHeight
525 : rect.bottom + window.scrollY,
526 right: window.innerWidth - rect.right + window.scrollX,
527 });
528 setOpenDropdownId(
529 openDropdownId === itemId ? null : itemId,
530 );
531 }}
532 className="h-8 w-8 hover:bg-gray-100 dark:hover:bg-gray-700"
533 aria-label={__("More actions", "yatra")}
534 data-dropdown-trigger
535 >
536 <MoreVertical className="w-4 h-4" />
537 </Button>
538
539 {openDropdownId === itemId && dropdownPosition && (
540 <div
541 className="fixed min-w-[180px] w-max bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-2xl py-1"
542 style={{
543 top: `${dropdownPosition.top}px`,
544 right: `${dropdownPosition.right}px`,
545 zIndex: 999999,
546 }}
547 data-dropdown-content
548 onClick={(e) => e.stopPropagation()}
549 >
550 {actions
551 .filter(
552 (action) => !action.condition || action.condition(item),
553 )
554 .map((action) => (
555 <button
556 key={action.key}
557 type="button"
558 onClick={(e) => {
559 e.preventDefault();
560 e.stopPropagation();
561 action.onClick(item);
562 setOpenDropdownId(null);
563 }}
564 className={`w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-3 transition-colors cursor-pointer whitespace-nowrap ${
565 action.variant === "destructive"
566 ? "text-red-600 dark:text-red-400"
567 : action.variant === "outline"
568 ? "text-blue-600 dark:text-blue-400"
569 : "text-gray-700 dark:text-gray-300"
570 }`}
571 >
572 {action.icon}
573 {action.label}
574 </button>
575 ))}
576 </div>
577 )}
578 </div>
579 </TableCell>
580 )}
581 </TableRow>
582
583 {/* Render children if expanded */}
584 {hasChildren &&
585 isExpanded &&
586 children.map((child, childIndex) =>
587 renderHierarchicalRow(child, childIndex, true),
588 )}
589 </React.Fragment>
590 );
591 };
592
593 // Render flat row (original behavior)
594 const renderFlatRow = (item: any, index: number): React.ReactNode => {
595 const itemId = getItemId(item);
596 const itemStatus = getItemStatus(item);
597 const isTrash = itemStatus === "trash" || statusFilter === "trash";
598
599 return (
600 <TableRow
601 key={itemId}
602 className={
603 isTrash
604 ? "bg-red-50/30 dark:bg-red-900/10 opacity-75 hover:bg-red-50/50 dark:hover:bg-red-900/20"
605 : ""
606 }
607 >
608 {onSelectItem && (
609 <TableCell>
610 <input
611 type="checkbox"
612 className="rounded border-gray-300 dark:border-gray-600"
613 checked={selectedItemIds.includes(itemId)}
614 onChange={(e) => onSelectItem(itemId, e.target.checked)}
615 aria-label={__("Select item", "yatra")}
616 />
617 </TableCell>
618 )}
619 {columns
620 .filter((col) => col.visible !== false)
621 .map((column) => (
622 <TableCell
623 key={`${column.key}-${itemId}`}
624 className={isTrash ? "text-gray-400 dark:text-gray-600" : ""}
625 >
626 {column.render ? column.render(item, index) : item[column.key]}
627 </TableCell>
628 ))}
629 {actions.length > 0 && (
630 <TableCell className="text-right">
631 <div className="relative inline-block">
632 <Button
633 variant="ghost"
634 size="icon"
635 onClick={(e) => {
636 e.preventDefault();
637 e.stopPropagation();
638 const button = e.currentTarget;
639 const rect = button.getBoundingClientRect();
640
641 // Calculate dropdown height (estimate based on number of actions)
642 const dropdownHeight =
643 actions.filter((a) => !a.condition || a.condition(item))
644 .length *
645 40 +
646 16;
647 const spaceBelow = window.innerHeight - rect.bottom;
648 const spaceAbove = rect.top;
649
650 // Position dropdown above if not enough space below
651 const shouldPositionAbove =
652 spaceBelow < dropdownHeight && spaceAbove > dropdownHeight;
653
654 setDropdownPosition({
655 top: shouldPositionAbove
656 ? rect.top + window.scrollY - dropdownHeight
657 : rect.bottom + window.scrollY,
658 right: window.innerWidth - rect.right + window.scrollX,
659 });
660 setOpenDropdownId(openDropdownId === itemId ? null : itemId);
661 }}
662 className="h-8 w-8 hover:bg-gray-100 dark:hover:bg-gray-700"
663 aria-label={__("More actions", "yatra")}
664 data-dropdown-trigger
665 >
666 <MoreVertical className="w-4 h-4" />
667 </Button>
668
669 {openDropdownId === itemId && dropdownPosition && (
670 <div
671 className="fixed min-w-[180px] w-max bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-2xl py-1"
672 style={{
673 top: `${dropdownPosition.top}px`,
674 right: `${dropdownPosition.right}px`,
675 zIndex: 999999,
676 }}
677 data-dropdown-content
678 onClick={(e) => e.stopPropagation()}
679 >
680 {actions
681 .filter(
682 (action) => !action.condition || action.condition(item),
683 )
684 .map((action) => (
685 <button
686 key={action.key}
687 type="button"
688 onClick={(e) => {
689 e.preventDefault();
690 e.stopPropagation();
691 action.onClick(item);
692 setOpenDropdownId(null);
693 }}
694 className={`w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-3 transition-colors cursor-pointer whitespace-nowrap ${
695 action.variant === "destructive"
696 ? "text-red-600 dark:text-red-400"
697 : action.variant === "outline"
698 ? "text-blue-600 dark:text-blue-400"
699 : "text-gray-700 dark:text-gray-300"
700 }`}
701 >
702 {action.icon}
703 {action.label}
704 </button>
705 ))}
706 </div>
707 )}
708 </div>
709 </TableCell>
710 )}
711 </TableRow>
712 );
713 };
714
715 // Render table data
716 const renderTable = () => {
717 if (isLoading) return renderSkeleton();
718 if (isError) return renderError();
719 if (data.length === 0) return renderEmpty();
720
721 return (
722 <UITable>
723 <TableHeader>
724 <TableRow>
725 {/* Expand/collapse column for hierarchical tables */}
726 {isHierarchical && <TableHead className="w-12"></TableHead>}
727 {onSelectItem && onSelectAll && (
728 <TableHead className="w-12">
729 <input
730 type="checkbox"
731 className="rounded border-gray-300 dark:border-gray-600"
732 checked={isAllSelected}
733 onChange={(e) => onSelectAll(e.target.checked)}
734 aria-label={__("Select all items", "yatra")}
735 />
736 </TableHead>
737 )}
738 {columns
739 .filter((col) => col.visible !== false)
740 .map((column) => (
741 <TableHead key={column.key} className={column.width}>
742 {column.sortable && onSort ? (
743 <button
744 onClick={() => onSort(column.key)}
745 className="flex items-center hover:text-gray-900 dark:hover:text-white transition-colors"
746 >
747 {column.label}
748 {getSortIcon && getSortIcon(column.key)}
749 </button>
750 ) : (
751 column.label
752 )}
753 </TableHead>
754 ))}
755 {actions.length > 0 && (
756 <TableHead className="text-right w-[100px]">
757 {__("Actions", "yatra")}
758 </TableHead>
759 )}
760 </TableRow>
761 </TableHeader>
762 <TableBody>
763 {data.map((item, index) =>
764 isHierarchical
765 ? renderHierarchicalRow(item, index)
766 : renderFlatRow(item, index),
767 )}
768 </TableBody>
769 </UITable>
770 );
771 };
772
773 return (
774 <ConditionalRender capability={capability}>
775 {renderTable()}
776 </ConditionalRender>
777 );
778 };
779