vasuzex 2.3.14 → 2.3.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/frontend/react-ui/components/BreadCrumb/BreadCrumb.jsx +3 -3
- package/frontend/react-ui/components/DataTable/CellComponents/RowActionsCell.jsx +26 -5
- package/frontend/react-ui/components/DataTable/DataTable.jsx +55 -7
- package/frontend/react-ui/components/DataTable/Filters.jsx +36 -40
- package/frontend/react-ui/components/DataTable/MobileCardList.jsx +226 -0
- package/frontend/react-ui/components/DataTable/Pagination.jsx +120 -57
- package/frontend/react-ui/hooks/index.js +1 -0
- package/frontend/react-ui/hooks/useMobileDetect.js +30 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,47 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Vasuzex will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [2.3.15] - 2026-04-20
|
|
6
|
+
|
|
7
|
+
### ✨ Added
|
|
8
|
+
|
|
9
|
+
#### DataTable — Mobile Card View (`vasuzex/react`)
|
|
10
|
+
|
|
11
|
+
- **New `mobileCardView` prop** — When `mobileCardView={true}`, DataTable renders `MobileCardList` instead of the standard table on viewports narrower than 640px (Tailwind `sm` breakpoint)
|
|
12
|
+
- **`MobileCardList` component** — Stacked card layout optimised for touch
|
|
13
|
+
- Column priority system: mark columns as `priority="primary"` (shown full-width, no label), `priority="secondary"` (compact label:value rows), or omit to auto-assign first 3 as primary and rest as secondary
|
|
14
|
+
- Supports full action set: view, edit, delete, hard-delete (🔥), and restore actions
|
|
15
|
+
- Skeleton loading state with `animate-pulse` placeholders
|
|
16
|
+
- Empty-state text support via `emptyText` prop
|
|
17
|
+
- **New `useMobileDetect` hook** — `window.matchMedia`-based viewport detection; no resize listener polling, reacts instantly, SSR-safe fallback
|
|
18
|
+
|
|
19
|
+
#### Pagination — Mobile Responsive Layout (`vasuzex/react`)
|
|
20
|
+
|
|
21
|
+
- **Mobile compact mode** (visible on `sm:hidden`): Prev / "Page X of Y" + jump-to-page input / Next
|
|
22
|
+
- Jump-to-page input: type a page number and press Enter (or blur) to navigate directly
|
|
23
|
+
- **Desktop unchanged**: full numbered page buttons with ellipsis remain as before
|
|
24
|
+
|
|
25
|
+
#### BreadCrumb — Responsive Props (`vasuzex/react`)
|
|
26
|
+
|
|
27
|
+
- Added `className` prop for external style overrides
|
|
28
|
+
- Responsive typography and spacing: `text-lg sm:text-xl`, `gap-2 sm:gap-3`, `mb-3 sm:mb-6`
|
|
29
|
+
|
|
30
|
+
### 🐛 Fixed
|
|
31
|
+
|
|
32
|
+
#### Filters — Responsive Layout (`vasuzex/react`)
|
|
33
|
+
|
|
34
|
+
- Moved refresh button inline with rows-per-page selector and showing info (no longer isolated in a separate row)
|
|
35
|
+
- Shortened label: "Rows:" instead of "Rows per page:" — saves horizontal space on small screens
|
|
36
|
+
- Compact showing info format: `1–10 of 50` instead of `Showing 1 to 10 of 50 items`
|
|
37
|
+
|
|
38
|
+
#### RowActionsCell — Dropdown Clipping Fix (`vasuzex/react`)
|
|
39
|
+
|
|
40
|
+
- **Root cause**: Dropdown was positioned `absolute` inside the table cell, causing it to be clipped by `overflow-x-auto` on the table container
|
|
41
|
+
- **Fix**: Dropdown now uses `position: fixed` anchored to the trigger button via `getBoundingClientRect()`, with `z-index: 9999` — never clipped by any overflow parent
|
|
42
|
+
- Fixed click-outside detection: separate `btnRef` and `dropdownRef` so clicking the button or inside the menu never incorrectly closes the dropdown
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
5
46
|
## [2.3.14] - 2026-04-08
|
|
6
47
|
|
|
7
48
|
### 🐛 Fixed
|
|
@@ -28,10 +28,10 @@ const ChevronRightIcon = (props) => (
|
|
|
28
28
|
</svg>
|
|
29
29
|
);
|
|
30
30
|
|
|
31
|
-
export const BreadCrumb = ({ title, addLink, addLabel, addIcon, items }) => (
|
|
32
|
-
<div className=
|
|
31
|
+
export const BreadCrumb = ({ title, addLink, addLabel, addIcon, items, className }) => (
|
|
32
|
+
<div className={`flex flex-wrap items-center justify-between gap-2 sm:gap-3 mb-3 sm:mb-6 ${className || ''}`}>
|
|
33
33
|
<div>
|
|
34
|
-
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{title}</h2>
|
|
34
|
+
<h2 className="text-lg sm:text-xl font-semibold text-gray-900 dark:text-white">{title}</h2>
|
|
35
35
|
<nav className="mt-1">
|
|
36
36
|
<ol className="flex items-center gap-1.5">
|
|
37
37
|
{items.map((item, idx) => (
|
|
@@ -44,15 +44,31 @@ export function RowActionsCell({
|
|
|
44
44
|
}) {
|
|
45
45
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
|
46
46
|
const menuRef = useRef(null);
|
|
47
|
-
|
|
47
|
+
const btnRef = useRef(null);
|
|
48
|
+
const dropdownRef = useRef(null);
|
|
49
|
+
const [menuPos, setMenuPos] = useState({ top: 0, right: 0 });
|
|
50
|
+
|
|
48
51
|
// Determine which approval actions to show based on current status
|
|
49
52
|
const showApprove = hasApproval && (row.approval_status === 'pending' || row.approval_status === 'rejected');
|
|
50
53
|
const showReject = hasApproval && (row.approval_status === 'pending' || row.approval_status === 'approved');
|
|
51
54
|
|
|
55
|
+
const handleToggleMenu = () => {
|
|
56
|
+
if (!isMenuOpen && btnRef.current) {
|
|
57
|
+
const rect = btnRef.current.getBoundingClientRect();
|
|
58
|
+
setMenuPos({
|
|
59
|
+
top: rect.bottom + 4,
|
|
60
|
+
right: window.innerWidth - rect.right,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
setIsMenuOpen((prev) => !prev);
|
|
64
|
+
};
|
|
65
|
+
|
|
52
66
|
// Close menu when clicking outside
|
|
53
67
|
useEffect(() => {
|
|
54
68
|
const handleClickOutside = (event) => {
|
|
55
|
-
|
|
69
|
+
const clickedBtn = btnRef.current && btnRef.current.contains(event.target);
|
|
70
|
+
const clickedMenu = dropdownRef.current && dropdownRef.current.contains(event.target);
|
|
71
|
+
if (!clickedBtn && !clickedMenu) {
|
|
56
72
|
setIsMenuOpen(false);
|
|
57
73
|
}
|
|
58
74
|
};
|
|
@@ -98,9 +114,10 @@ export function RowActionsCell({
|
|
|
98
114
|
)}
|
|
99
115
|
|
|
100
116
|
{/* Overflow Menu - Secondary & Destructive Actions */}
|
|
101
|
-
<div className="relative"
|
|
117
|
+
<div className="relative">
|
|
102
118
|
<button
|
|
103
|
-
|
|
119
|
+
ref={btnRef}
|
|
120
|
+
onClick={handleToggleMenu}
|
|
104
121
|
className="p-1.5 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-md transition-colors dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-700"
|
|
105
122
|
title="More Actions"
|
|
106
123
|
aria-label="More Actions"
|
|
@@ -110,7 +127,11 @@ export function RowActionsCell({
|
|
|
110
127
|
</button>
|
|
111
128
|
|
|
112
129
|
{isMenuOpen && (
|
|
113
|
-
<div
|
|
130
|
+
<div
|
|
131
|
+
ref={dropdownRef}
|
|
132
|
+
style={{ position: 'fixed', top: menuPos.top, right: menuPos.right }}
|
|
133
|
+
className="w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-[9999] py-1"
|
|
134
|
+
>
|
|
114
135
|
{/* Approval Actions - Show based on current status */}
|
|
115
136
|
{(showApprove || showReject) && (
|
|
116
137
|
<>
|
|
@@ -17,10 +17,12 @@
|
|
|
17
17
|
|
|
18
18
|
import React from "react";
|
|
19
19
|
import { TableBody } from "./TableBody.jsx";
|
|
20
|
+
import { MobileCardList } from "./MobileCardList.jsx";
|
|
20
21
|
import { Filters } from "./Filters.jsx";
|
|
21
22
|
import { TableHeader } from "./TableHeader.jsx";
|
|
22
23
|
import { TableState } from "./TableState.jsx";
|
|
23
24
|
import { Pagination } from "./Pagination.jsx";
|
|
25
|
+
import { useMobileDetect } from "../../hooks/useMobileDetect.js";
|
|
24
26
|
|
|
25
27
|
// Conditional import for React Router (optional dependency)
|
|
26
28
|
let useSearchParamsHook = null;
|
|
@@ -58,6 +60,7 @@ try {
|
|
|
58
60
|
* @param {number} props.initialLimit - Initial rows per page
|
|
59
61
|
* @param {string} props.emptyText - Text to show when no data
|
|
60
62
|
* @param {boolean} props.persistState - Enable URL state persistence (default: true)
|
|
63
|
+
* @param {boolean} props.mobileCardView - Render as stacked cards on mobile < 640px (default: false)
|
|
61
64
|
*/
|
|
62
65
|
|
|
63
66
|
// URL params that DataTable owns — all other params (e.g. trashed) are preserved as-is
|
|
@@ -88,8 +91,11 @@ export function DataTable(props) {
|
|
|
88
91
|
trashable = false, // Enable trash/restore UI (Live / With Trash / Trash tabs)
|
|
89
92
|
restoreUrl, // URL template for restore, e.g. "/products/:id/restore"
|
|
90
93
|
initialTrashed = 'without', // Initial trash mode: 'without' | 'with' | 'only'
|
|
94
|
+
mobileCardView = false, // Render as stacked cards on mobile (< 640px)
|
|
91
95
|
} = props;
|
|
92
96
|
|
|
97
|
+
const isMobile = useMobileDetect(640);
|
|
98
|
+
|
|
93
99
|
// Validate that api client is provided
|
|
94
100
|
if (!api) {
|
|
95
101
|
throw new Error('DataTable requires "api" prop - pass your API client instance');
|
|
@@ -503,10 +509,12 @@ export function DataTable(props) {
|
|
|
503
509
|
setPage(newPage);
|
|
504
510
|
};
|
|
505
511
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
512
|
+
// Mobile card view — rendered when mobileCardView=true and on mobile
|
|
513
|
+
// Renders flat (no inner card) so the page's outer card is the visual container
|
|
514
|
+
if (mobileCardView && isMobile) {
|
|
515
|
+
return (
|
|
516
|
+
<div className="w-full text-sm">
|
|
517
|
+
{/* Filters row — sits at top of the page's outer card */}
|
|
510
518
|
<div className="border-b border-gray-100 p-3 dark:border-white/[0.05]">
|
|
511
519
|
<Filters
|
|
512
520
|
statusFilter={statusFilter}
|
|
@@ -523,7 +531,48 @@ export function DataTable(props) {
|
|
|
523
531
|
setTrashed={(val) => { setTrashed(val); setPage(1); }}
|
|
524
532
|
/>
|
|
525
533
|
</div>
|
|
526
|
-
<
|
|
534
|
+
<MobileCardList
|
|
535
|
+
api={api}
|
|
536
|
+
data={data}
|
|
537
|
+
columns={columns}
|
|
538
|
+
actions={actions}
|
|
539
|
+
loading={loading}
|
|
540
|
+
emptyText={emptyText}
|
|
541
|
+
resourceName={resourceName}
|
|
542
|
+
resourceIdField={resourceIdField}
|
|
543
|
+
onRefresh={() => setRefreshKey((k) => k + 1)}
|
|
544
|
+
trashMode={trashed}
|
|
545
|
+
restoreUrl={restoreUrl}
|
|
546
|
+
/>
|
|
547
|
+
{/* Pagination inside the page card — connected visually */}
|
|
548
|
+
{totalPages > 1 && (
|
|
549
|
+
<Pagination page={page} totalPages={totalPages} onPageChange={handlePageChange} />
|
|
550
|
+
)}
|
|
551
|
+
</div>
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return (
|
|
556
|
+
<div className="w-full text-sm">
|
|
557
|
+
<div className="border-b border-gray-100 p-3 dark:border-white/[0.05]">
|
|
558
|
+
<Filters
|
|
559
|
+
statusFilter={statusFilter}
|
|
560
|
+
setStatusFilter={setStatusFilter}
|
|
561
|
+
setPage={setPage}
|
|
562
|
+
page={page}
|
|
563
|
+
limit={limit}
|
|
564
|
+
setLimit={setLimit}
|
|
565
|
+
dataLength={data.length}
|
|
566
|
+
totalItems={totalItems}
|
|
567
|
+
onRefresh={() => setRefreshKey((k) => k + 1)}
|
|
568
|
+
trashable={trashable}
|
|
569
|
+
trashed={trashed}
|
|
570
|
+
setTrashed={(val) => { setTrashed(val); setPage(1); }}
|
|
571
|
+
/>
|
|
572
|
+
</div>
|
|
573
|
+
{/* Table — only this portion scrolls horizontally */}
|
|
574
|
+
<div className="overflow-x-auto">
|
|
575
|
+
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
|
527
576
|
<TableHeader
|
|
528
577
|
columns={columns}
|
|
529
578
|
actions={actions}
|
|
@@ -559,8 +608,7 @@ export function DataTable(props) {
|
|
|
559
608
|
</tbody>
|
|
560
609
|
</table>
|
|
561
610
|
</div>
|
|
562
|
-
|
|
563
|
-
{/* Pagination */}
|
|
611
|
+
{/* Pagination — inside the page's outer card, connected to the table visually */}
|
|
564
612
|
{totalPages > 1 && (
|
|
565
613
|
<Pagination page={page} totalPages={totalPages} onPageChange={handlePageChange} />
|
|
566
614
|
)}
|
|
@@ -25,35 +25,42 @@ export const Filters = ({
|
|
|
25
25
|
setTrashed,
|
|
26
26
|
}) => (
|
|
27
27
|
<div className="flex flex-col gap-2 px-2 py-2 text-xs text-gray-600 w-full">
|
|
28
|
-
{/*
|
|
29
|
-
<div className="flex items-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
<
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
value
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
>
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
<option value="100">100</option>
|
|
47
|
-
</select>
|
|
48
|
-
</div>
|
|
49
|
-
<span className="whitespace-nowrap mt-1">
|
|
50
|
-
Showing {totalItems === 0 ? 0 : (page - 1) * limit + 1} to{" "}
|
|
51
|
-
{Math.min(page * limit, totalItems)} of {totalItems} items
|
|
52
|
-
</span>
|
|
28
|
+
{/* Row 1: Rows-per-page selector + showing info + refresh (always fits on one line) */}
|
|
29
|
+
<div className="flex items-center gap-3 flex-wrap">
|
|
30
|
+
<div className="flex items-center gap-1.5 shrink-0">
|
|
31
|
+
<label className="text-xs text-gray-500">Rows:</label>
|
|
32
|
+
<select
|
|
33
|
+
value={limit}
|
|
34
|
+
onChange={(e) => {
|
|
35
|
+
setLimit(Number(e.target.value));
|
|
36
|
+
setPage(1);
|
|
37
|
+
}}
|
|
38
|
+
className="rounded border border-gray-300 bg-transparent px-2 py-1 text-xs outline-none dark:border-gray-700 dark:bg-gray-900"
|
|
39
|
+
>
|
|
40
|
+
<option value="5">5</option>
|
|
41
|
+
<option value="10">10</option>
|
|
42
|
+
<option value="25">25</option>
|
|
43
|
+
<option value="50">50</option>
|
|
44
|
+
<option value="100">100</option>
|
|
45
|
+
</select>
|
|
53
46
|
</div>
|
|
47
|
+
<span className="text-xs text-gray-500 shrink-0">
|
|
48
|
+
{totalItems === 0 ? "0 items" : `${(page - 1) * limit + 1}–${Math.min(page * limit, totalItems)} of ${totalItems}`}
|
|
49
|
+
</span>
|
|
50
|
+
{onRefresh && (
|
|
51
|
+
<button
|
|
52
|
+
onClick={onRefresh}
|
|
53
|
+
title="Refresh"
|
|
54
|
+
className="ml-auto p-1.5 rounded-md text-gray-500 hover:text-brand-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
|
55
|
+
>
|
|
56
|
+
<RefreshCw className="h-4 w-4" />
|
|
57
|
+
</button>
|
|
58
|
+
)}
|
|
59
|
+
</div>
|
|
54
60
|
|
|
55
|
-
|
|
56
|
-
|
|
61
|
+
{/* Row 2: Status filter + Trash tabs (wraps cleanly on small screens) */}
|
|
62
|
+
{((!trashable || trashed !== 'only') || trashable) && (
|
|
63
|
+
<div className="flex items-center flex-wrap gap-2">
|
|
57
64
|
{/* Status filter buttons — hidden when viewing only-trash */}
|
|
58
65
|
{(!trashable || trashed !== 'only') && (
|
|
59
66
|
<div className="flex items-center gap-1">
|
|
@@ -80,7 +87,7 @@ export const Filters = ({
|
|
|
80
87
|
|
|
81
88
|
{/* Trash tabs (only shown when trashable=true) */}
|
|
82
89
|
{trashable && (
|
|
83
|
-
<div className="flex items-center gap-1
|
|
90
|
+
<div className="flex items-center gap-1">
|
|
84
91
|
<Trash2 className="h-3 w-3 text-gray-400 mr-0.5" />
|
|
85
92
|
<button
|
|
86
93
|
onClick={() => { setTrashed('without'); setPage(1); }}
|
|
@@ -102,18 +109,7 @@ export const Filters = ({
|
|
|
102
109
|
</button>
|
|
103
110
|
</div>
|
|
104
111
|
)}
|
|
105
|
-
|
|
106
|
-
{/* Refresh button — always shown */}
|
|
107
|
-
{onRefresh && (
|
|
108
|
-
<button
|
|
109
|
-
onClick={onRefresh}
|
|
110
|
-
title="Refresh"
|
|
111
|
-
className="p-1.5 rounded-md text-gray-500 hover:text-brand-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
|
112
|
-
>
|
|
113
|
-
<RefreshCw className="h-4 w-4" />
|
|
114
|
-
</button>
|
|
115
|
-
)}
|
|
116
112
|
</div>
|
|
117
|
-
|
|
113
|
+
)}
|
|
118
114
|
</div>
|
|
119
115
|
);
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Link } from 'react-router-dom';
|
|
3
|
+
import {
|
|
4
|
+
applyActionDefaults,
|
|
5
|
+
createViewClickHandler,
|
|
6
|
+
createDeleteClickHandler,
|
|
7
|
+
createHardDeleteClickHandler,
|
|
8
|
+
createRestoreClickHandler,
|
|
9
|
+
} from './ActionDefaults.jsx';
|
|
10
|
+
|
|
11
|
+
// ─── MobileCardList ──────────────────────────────────────────────────────────
|
|
12
|
+
//
|
|
13
|
+
// Column priority system:
|
|
14
|
+
// primary — shown full-width, no label prefix (first = bold title)
|
|
15
|
+
// secondary — compact label:value pairs below a divider
|
|
16
|
+
// hidden-mobile — never shown
|
|
17
|
+
//
|
|
18
|
+
// If no column has priority set:
|
|
19
|
+
// first 3 columns → primary | rest → secondary
|
|
20
|
+
|
|
21
|
+
export function MobileCardList({
|
|
22
|
+
api,
|
|
23
|
+
data,
|
|
24
|
+
columns,
|
|
25
|
+
actions,
|
|
26
|
+
loading,
|
|
27
|
+
emptyText,
|
|
28
|
+
resourceName,
|
|
29
|
+
resourceIdField = 'id',
|
|
30
|
+
onRefresh,
|
|
31
|
+
trashMode,
|
|
32
|
+
restoreUrl,
|
|
33
|
+
}) {
|
|
34
|
+
const hasPriority = columns.some((c) => c.priority);
|
|
35
|
+
|
|
36
|
+
// Detect column-based actions (render fn inside field:'actions' column)
|
|
37
|
+
const actionsColumn = columns.find((c) => c.field === 'actions');
|
|
38
|
+
|
|
39
|
+
let primaryColumns, secondaryColumns;
|
|
40
|
+
if (hasPriority) {
|
|
41
|
+
primaryColumns = columns.filter((c) => c.priority === 'primary' && c.field !== 'actions');
|
|
42
|
+
secondaryColumns = columns.filter((c) => c.priority === 'secondary' && c.field !== 'actions');
|
|
43
|
+
} else {
|
|
44
|
+
// No priority set — treat first 3 visible columns as primary, rest as secondary
|
|
45
|
+
const visible = columns.filter((c) => c.field !== 'actions');
|
|
46
|
+
primaryColumns = visible.slice(0, 3);
|
|
47
|
+
secondaryColumns = visible.slice(3);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const renderCell = (col, row) => {
|
|
51
|
+
if (col.render) return col.render(row);
|
|
52
|
+
const val = row[col.field];
|
|
53
|
+
return (val === undefined || val === null) ? '—' : String(val);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// ── Skeleton loading ───────────────────────────────────────────────────────
|
|
57
|
+
if (loading) {
|
|
58
|
+
return (
|
|
59
|
+
<div className="divide-y divide-gray-100">
|
|
60
|
+
{Array.from({ length: 4 }).map((_, i) => (
|
|
61
|
+
<div key={i} className="p-4 animate-pulse">
|
|
62
|
+
<div className="h-4 bg-gray-200 rounded w-1/2 mb-2" />
|
|
63
|
+
<div className="h-3 bg-gray-100 rounded w-3/4 mb-1.5" />
|
|
64
|
+
<div className="h-3 bg-gray-100 rounded w-2/3 mb-3" />
|
|
65
|
+
<div className="flex gap-2 mt-3">
|
|
66
|
+
<div className="h-7 bg-gray-100 rounded w-16" />
|
|
67
|
+
<div className="h-7 bg-gray-100 rounded w-16" />
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
))}
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Empty state ────────────────────────────────────────────────────────────
|
|
76
|
+
if (!data || data.length === 0) {
|
|
77
|
+
return (
|
|
78
|
+
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
|
|
79
|
+
<div className="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center mb-3">
|
|
80
|
+
<svg className="w-5 h-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
81
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
|
82
|
+
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
|
83
|
+
</svg>
|
|
84
|
+
</div>
|
|
85
|
+
<p className="text-sm text-gray-500">{emptyText || 'No data found'}</p>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Card list ──────────────────────────────────────────────────────────────
|
|
91
|
+
return (
|
|
92
|
+
<div className="divide-y divide-gray-100">
|
|
93
|
+
{data.map((row, idx) => {
|
|
94
|
+
const rowId = row[resourceIdField] ?? row.id ?? row._id ?? idx;
|
|
95
|
+
const isTrashed = !!row.deleted_at;
|
|
96
|
+
const renderedColumnActions = actionsColumn && actionsColumn.render
|
|
97
|
+
? actionsColumn.render(row)
|
|
98
|
+
: null;
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<div key={rowId} className={`p-4 ${isTrashed ? 'bg-red-50/40' : ''}`}>
|
|
102
|
+
|
|
103
|
+
{/* ── Primary columns: title + body rows, no label prefix ── */}
|
|
104
|
+
<div className="space-y-1.5 mb-3">
|
|
105
|
+
{primaryColumns.map((col, colIdx) => {
|
|
106
|
+
const content = renderCell(col, row);
|
|
107
|
+
return (
|
|
108
|
+
<div
|
|
109
|
+
key={col.field}
|
|
110
|
+
className={colIdx === 0
|
|
111
|
+
? 'text-sm font-semibold text-gray-900 leading-snug'
|
|
112
|
+
: 'text-sm text-gray-700 leading-snug'
|
|
113
|
+
}
|
|
114
|
+
>
|
|
115
|
+
{content}
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
})}
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
{/* ── Secondary columns: compact label:value pairs ── */}
|
|
122
|
+
{secondaryColumns.length > 0 && (
|
|
123
|
+
<div className="border-t border-gray-100 pt-2 mb-3 space-y-1">
|
|
124
|
+
{secondaryColumns.map((col) => (
|
|
125
|
+
<div key={col.field} className="flex items-start gap-2">
|
|
126
|
+
<span className="text-xs text-gray-400 w-20 shrink-0 pt-0.5">{col.label}</span>
|
|
127
|
+
<div className="text-xs text-gray-600 flex-1 min-w-0 break-words">{renderCell(col, row)}</div>
|
|
128
|
+
</div>
|
|
129
|
+
))}
|
|
130
|
+
</div>
|
|
131
|
+
)}
|
|
132
|
+
|
|
133
|
+
{/* ── Trashed indicator ── */}
|
|
134
|
+
{isTrashed && (
|
|
135
|
+
<div className="mb-2">
|
|
136
|
+
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium bg-red-100 text-red-700 rounded-full">
|
|
137
|
+
Deleted
|
|
138
|
+
</span>
|
|
139
|
+
</div>
|
|
140
|
+
)}
|
|
141
|
+
|
|
142
|
+
{/* ── Actions ── */}
|
|
143
|
+
{/* Column-based actions (e.g. RowActionsCellExtended) take precedence */}
|
|
144
|
+
{renderedColumnActions ? (
|
|
145
|
+
<div className="mt-2 pt-2 border-t border-gray-100">
|
|
146
|
+
{renderedColumnActions}
|
|
147
|
+
</div>
|
|
148
|
+
) : actions && actions.length > 0 && (
|
|
149
|
+
<div className="flex items-center gap-1.5 flex-wrap">
|
|
150
|
+
{actions.map((actionDef) => {
|
|
151
|
+
const action = applyActionDefaults(actionDef, resourceName, resourceIdField);
|
|
152
|
+
const Icon = action.icon;
|
|
153
|
+
|
|
154
|
+
// Switch toggle — skip in mobile card
|
|
155
|
+
if (action.name === 'switch') return null;
|
|
156
|
+
|
|
157
|
+
// Trash-mode visibility rules
|
|
158
|
+
const isDeleteAction = action.name === 'delete';
|
|
159
|
+
const isHardDelete = action.name === 'hardDelete';
|
|
160
|
+
const isRestore = action.name === 'restore';
|
|
161
|
+
if (trashMode === 'only' && isDeleteAction) return null;
|
|
162
|
+
if (trashMode !== 'only' && (isHardDelete || isRestore)) return null;
|
|
163
|
+
|
|
164
|
+
// Resolve onClick
|
|
165
|
+
let onClick = action.onClick ? () => action.onClick(row) : null;
|
|
166
|
+
if (!onClick) {
|
|
167
|
+
if (isHardDelete && action.deleteUrl) {
|
|
168
|
+
const handler = createHardDeleteClickHandler(api, action.deleteUrl, action.confirmMessage, resourceIdField, { onRefresh });
|
|
169
|
+
onClick = () => handler(row);
|
|
170
|
+
} else if (isRestore && restoreUrl) {
|
|
171
|
+
const handler = createRestoreClickHandler(api, restoreUrl, resourceIdField, { onRefresh });
|
|
172
|
+
onClick = () => handler(row);
|
|
173
|
+
} else if (isDeleteAction && action.deleteUrl) {
|
|
174
|
+
const handler = createDeleteClickHandler(api, action.deleteUrl, action.confirmMessage, resourceIdField, { onRefresh });
|
|
175
|
+
onClick = () => handler(row);
|
|
176
|
+
} else if (action.name === 'view' && action.apiUrl) {
|
|
177
|
+
const handler = createViewClickHandler(api, action.apiUrl, action.modalEvent, resourceIdField);
|
|
178
|
+
onClick = () => handler(row);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const btnBase = 'inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium border transition-colors';
|
|
183
|
+
const btnColor = (isDeleteAction || isHardDelete)
|
|
184
|
+
? 'border-red-200 bg-white text-red-600 hover:bg-red-50'
|
|
185
|
+
: isRestore
|
|
186
|
+
? 'border-green-200 bg-white text-green-700 hover:bg-green-50'
|
|
187
|
+
: 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50';
|
|
188
|
+
|
|
189
|
+
if (action.type === 'link' && action.getHref) {
|
|
190
|
+
return (
|
|
191
|
+
<Link
|
|
192
|
+
key={action.name || action.label}
|
|
193
|
+
to={action.getHref(row)}
|
|
194
|
+
title={action.title || action.label}
|
|
195
|
+
className={`${btnBase} ${btnColor} ${action.extraClass || ''}`}
|
|
196
|
+
>
|
|
197
|
+
{Icon && <Icon size={12} />}
|
|
198
|
+
<span>{action.label || action.title}</span>
|
|
199
|
+
</Link>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
<button
|
|
205
|
+
key={action.name || action.label}
|
|
206
|
+
type="button"
|
|
207
|
+
title={action.title || action.label}
|
|
208
|
+
onClick={onClick}
|
|
209
|
+
className={`${btnBase} ${btnColor} ${action.extraClass || ''}`}
|
|
210
|
+
>
|
|
211
|
+
{Icon && <Icon size={12} />}
|
|
212
|
+
<span>{action.label || action.title}</span>
|
|
213
|
+
</button>
|
|
214
|
+
);
|
|
215
|
+
})}
|
|
216
|
+
</div>
|
|
217
|
+
)}
|
|
218
|
+
</div>
|
|
219
|
+
);
|
|
220
|
+
})}
|
|
221
|
+
</div>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export default MobileCardList;
|
|
226
|
+
|
|
@@ -1,72 +1,135 @@
|
|
|
1
|
-
import React from "react";
|
|
1
|
+
import React, { useState } from "react";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Pagination Component - Production Ready
|
|
5
5
|
*
|
|
6
6
|
* Pagination controls with page numbers, previous/next buttons
|
|
7
|
+
* Mobile: compact Prev / Page X of Y (jump input) / Next layout
|
|
8
|
+
* Desktop: full numbered page buttons
|
|
7
9
|
*
|
|
8
10
|
* @module components/DataTable/Pagination
|
|
9
11
|
*/
|
|
10
|
-
export const Pagination = ({ page, totalPages, onPageChange }) =>
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
12
|
+
export const Pagination = ({ page, totalPages, onPageChange }) => {
|
|
13
|
+
const [jumpValue, setJumpValue] = useState('');
|
|
14
|
+
|
|
15
|
+
const handleJump = (e) => {
|
|
16
|
+
if (e.key === 'Enter' || e.type === 'blur') {
|
|
17
|
+
const val = parseInt(jumpValue, 10);
|
|
18
|
+
if (!isNaN(val) && val >= 1 && val <= totalPages) {
|
|
19
|
+
onPageChange(val);
|
|
20
|
+
}
|
|
21
|
+
setJumpValue('');
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className="border-t border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
|
|
27
|
+
|
|
28
|
+
{/* ── Mobile: compact layout (hidden on sm+) ── */}
|
|
29
|
+
<div className="sm:hidden px-3 py-3 space-y-2">
|
|
30
|
+
<div className="text-center text-xs text-gray-600 dark:text-gray-400">
|
|
31
|
+
Page {page} of {totalPages}
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<div className="grid grid-cols-2 gap-2">
|
|
35
|
+
<button
|
|
36
|
+
onClick={() => onPageChange(page - 1)}
|
|
37
|
+
disabled={page === 1}
|
|
38
|
+
className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
|
39
|
+
>
|
|
40
|
+
Previous
|
|
41
|
+
</button>
|
|
42
|
+
|
|
43
|
+
<button
|
|
44
|
+
onClick={() => onPageChange(page + 1)}
|
|
45
|
+
disabled={page === totalPages}
|
|
46
|
+
className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
|
47
|
+
>
|
|
48
|
+
Next
|
|
49
|
+
</button>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<div className="flex items-center justify-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
|
53
|
+
<span>Go to</span>
|
|
54
|
+
<input
|
|
55
|
+
type="number"
|
|
56
|
+
min={1}
|
|
57
|
+
max={totalPages}
|
|
58
|
+
value={jumpValue}
|
|
59
|
+
onChange={(e) => setJumpValue(e.target.value)}
|
|
60
|
+
onKeyDown={handleJump}
|
|
61
|
+
onBlur={handleJump}
|
|
62
|
+
placeholder="page"
|
|
63
|
+
className="w-16 rounded border border-gray-300 px-2 py-0.5 text-xs text-center outline-none focus:border-brand-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
|
64
|
+
/>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
{/* ── Desktop: full numbered layout (hidden below sm) ── */}
|
|
69
|
+
<div className="hidden sm:flex items-center justify-between px-6 py-4">
|
|
70
|
+
<div className="text-sm text-gray-700 dark:text-gray-400">
|
|
71
|
+
Page {page} of {totalPages}
|
|
72
|
+
</div>
|
|
73
|
+
<div className="flex gap-2 items-center">
|
|
44
74
|
<button
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
className=
|
|
75
|
+
onClick={() => onPageChange(page - 1)}
|
|
76
|
+
disabled={page === 1}
|
|
77
|
+
className="rounded-lg border border-gray-300 px-3 py-1 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
|
48
78
|
>
|
|
49
|
-
|
|
79
|
+
Previous
|
|
50
80
|
</button>
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
81
|
+
{page > 3 && (
|
|
82
|
+
<>
|
|
83
|
+
<button
|
|
84
|
+
onClick={() => onPageChange(1)}
|
|
85
|
+
className={`rounded-lg border px-3 py-1 text-sm font-medium mx-0.5 ${page === 1 ? "bg-brand-600 text-white border-brand-600" : "bg-white text-gray-700 border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-700"}`}
|
|
86
|
+
>
|
|
87
|
+
1
|
|
88
|
+
</button>
|
|
89
|
+
<span className="text-gray-400">...</span>
|
|
90
|
+
</>
|
|
91
|
+
)}
|
|
92
|
+
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
|
93
|
+
.filter(
|
|
94
|
+
(p) =>
|
|
95
|
+
p === page ||
|
|
96
|
+
p === page - 1 ||
|
|
97
|
+
p === page + 1 ||
|
|
98
|
+
(page <= 3 && p <= 4) ||
|
|
99
|
+
(page >= totalPages - 2 && p >= totalPages - 3),
|
|
100
|
+
)
|
|
101
|
+
.map((p) => (
|
|
102
|
+
<button
|
|
103
|
+
key={p}
|
|
104
|
+
onClick={() => onPageChange(p)}
|
|
105
|
+
className={`rounded-lg border px-3 py-1 text-sm font-medium mx-0.5 ${p === page ? "bg-brand-600 text-white border-brand-600" : "bg-white text-gray-700 border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-700"}`}
|
|
106
|
+
>
|
|
107
|
+
{p}
|
|
108
|
+
</button>
|
|
109
|
+
))}
|
|
110
|
+
{page < totalPages - 2 && (
|
|
111
|
+
<>
|
|
112
|
+
<span className="text-gray-400">...</span>
|
|
113
|
+
<button
|
|
114
|
+
onClick={() => onPageChange(totalPages)}
|
|
115
|
+
className={`rounded-lg border px-3 py-1 text-sm font-medium mx-0.5 ${page === totalPages ? "bg-brand-600 text-white border-brand-600" : "bg-white text-gray-700 border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-700"}`}
|
|
116
|
+
>
|
|
117
|
+
{totalPages}
|
|
118
|
+
</button>
|
|
119
|
+
</>
|
|
120
|
+
)}
|
|
55
121
|
<button
|
|
56
|
-
onClick={() => onPageChange(
|
|
57
|
-
|
|
122
|
+
onClick={() => onPageChange(page + 1)}
|
|
123
|
+
disabled={page === totalPages}
|
|
124
|
+
className="rounded-lg border border-gray-300 px-3 py-1 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
|
58
125
|
>
|
|
59
|
-
|
|
126
|
+
Next
|
|
60
127
|
</button>
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
onClick={() => onPageChange(page + 1)}
|
|
65
|
-
disabled={page === totalPages}
|
|
66
|
-
className="rounded-lg border border-gray-300 px-3 py-1 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
|
67
|
-
>
|
|
68
|
-
Next
|
|
69
|
-
</button>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
70
131
|
</div>
|
|
71
|
-
|
|
72
|
-
|
|
132
|
+
);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
|
|
@@ -15,3 +15,4 @@ export { useFocusTrap } from './useFocusTrap.js';
|
|
|
15
15
|
export { useAnnouncer } from './useAnnouncer.js';
|
|
16
16
|
export { useKeyboardNavigation } from './useKeyboardNavigation.js';
|
|
17
17
|
export { useListNavigation } from './useListNavigation.js';
|
|
18
|
+
export { useMobileDetect } from './useMobileDetect.js';
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useMobileDetect
|
|
5
|
+
*
|
|
6
|
+
* Detects whether the viewport is below the given breakpoint using
|
|
7
|
+
* window.matchMedia — no polling, no resize listeners, reacts instantly.
|
|
8
|
+
*
|
|
9
|
+
* @param {number} breakpoint - Max-width in px (default 640 = Tailwind `sm`)
|
|
10
|
+
* @returns {boolean} true when viewport < breakpoint
|
|
11
|
+
*/
|
|
12
|
+
export function useMobileDetect(breakpoint = 640) {
|
|
13
|
+
const getIsMobile = () =>
|
|
14
|
+
typeof window !== 'undefined'
|
|
15
|
+
? window.matchMedia(`(max-width: ${breakpoint - 1}px)`).matches
|
|
16
|
+
: false;
|
|
17
|
+
|
|
18
|
+
const [isMobile, setIsMobile] = useState(getIsMobile);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const mq = window.matchMedia(`(max-width: ${breakpoint - 1}px)`);
|
|
22
|
+
const handler = (e) => setIsMobile(e.matches);
|
|
23
|
+
// Set immediately in case of SSR mismatch
|
|
24
|
+
setIsMobile(mq.matches);
|
|
25
|
+
mq.addEventListener('change', handler);
|
|
26
|
+
return () => mq.removeEventListener('change', handler);
|
|
27
|
+
}, [breakpoint]);
|
|
28
|
+
|
|
29
|
+
return isMobile;
|
|
30
|
+
}
|