proje-react-panel 1.0.14 → 1.0.16
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/.cursor/rules.md +122 -0
- package/.cursor/settings.json +57 -0
- package/.eslintrc.js +5 -0
- package/.eslintrc.json +26 -0
- package/.prettierrc +10 -0
- package/.vscode/launch.json +27 -0
- package/.vscode/settings.json +8 -0
- package/PTD.md +234 -0
- package/README.md +62 -28
- package/dist/api/CrudApi.d.ts +12 -0
- package/dist/components/Panel.d.ts +2 -2
- package/dist/components/components/Checkbox.d.ts +6 -0
- package/dist/components/components/Counter.d.ts +9 -0
- package/dist/components/components/FormField.d.ts +13 -0
- package/dist/components/components/ImageUploader.d.ts +15 -0
- package/dist/components/components/InnerForm.d.ts +12 -0
- package/dist/components/components/LoadingScreen.d.ts +2 -0
- package/dist/components/components/index.d.ts +8 -0
- package/dist/components/components/list/Datagrid.d.ts +13 -0
- package/dist/components/components/list/EmptyList.d.ts +2 -0
- package/dist/components/components/list/FilterPopup.d.ts +11 -0
- package/dist/components/components/list/ListPage.d.ts +22 -0
- package/dist/components/components/list/Pagination.d.ts +11 -0
- package/dist/components/components/list/index.d.ts +0 -0
- package/dist/components/layout/Layout.d.ts +2 -1
- package/dist/components/layout/SideBar.d.ts +4 -3
- package/dist/components/layout/index.d.ts +2 -0
- package/dist/components/list/Datagrid.d.ts +12 -0
- package/dist/components/list/EmptyList.d.ts +2 -0
- package/dist/components/list/FilterPopup.d.ts +10 -0
- package/dist/components/list/Pagination.d.ts +11 -0
- package/dist/components/list/index.d.ts +0 -0
- package/dist/{src/screens → components/pages}/ControllerDetails.d.ts +1 -1
- package/dist/components/pages/FormPage.d.ts +12 -0
- package/dist/components/pages/ListPage.d.ts +18 -0
- package/dist/components/pages/Login.d.ts +13 -0
- package/dist/decorators/form/Form.d.ts +6 -0
- package/dist/decorators/form/FormOptions.d.ts +7 -0
- package/dist/decorators/form/Input.d.ts +17 -0
- package/dist/decorators/form/getFormFields.d.ts +3 -0
- package/dist/decorators/list/Cell.d.ts +21 -0
- package/dist/decorators/list/GetCellFields.d.ts +2 -0
- package/dist/decorators/list/ImageCell.d.ts +6 -0
- package/dist/decorators/list/List.d.ts +28 -0
- package/dist/decorators/list/ListData.d.ts +6 -0
- package/dist/decorators/list/getListFields.d.ts +2 -0
- package/dist/index.cjs.js +1 -1
- package/dist/index.d.ts +20 -10
- package/dist/index.esm.js +1 -1
- package/dist/initPanel.d.ts +2 -2
- package/dist/store/store.d.ts +1 -5
- package/dist/types/AnyClass.d.ts +1 -0
- package/dist/types/ScreenCreatorData.d.ts +5 -3
- package/dist/types/initPanelOptions.d.ts +0 -6
- package/dist/utils/format.d.ts +1 -0
- package/dist/utils/getFields.d.ts +2 -1
- package/package.json +13 -6
- package/src/api/CrudApi.ts +30 -11
- package/src/assets/icons/svg/create.svg +9 -0
- package/src/assets/icons/svg/filter.svg +3 -0
- package/src/assets/icons/svg/pencil.svg +8 -0
- package/src/assets/icons/svg/search.svg +8 -0
- package/src/assets/icons/svg/trash.svg +8 -0
- package/src/components/Panel.tsx +11 -11
- package/src/components/components/Checkbox.tsx +9 -0
- package/src/components/components/Counter.tsx +51 -0
- package/src/components/components/FormField.tsx +94 -0
- package/src/components/components/ImageUploader.tsx +301 -0
- package/src/components/components/InnerForm.tsx +74 -0
- package/src/components/components/LoadingScreen.tsx +12 -0
- package/src/components/components/index.ts +8 -0
- package/src/components/components/list/Datagrid.tsx +121 -0
- package/src/components/components/list/EmptyList.tsx +26 -0
- package/src/components/components/list/FilterPopup.tsx +202 -0
- package/src/components/components/list/ListPage.tsx +178 -0
- package/src/components/components/list/Pagination.tsx +110 -0
- package/src/components/components/list/index.ts +1 -0
- package/src/components/layout/Layout.tsx +8 -1
- package/src/components/layout/SideBar.tsx +103 -31
- package/src/components/layout/index.ts +2 -0
- package/src/components/pages/ControllerDetails.tsx +37 -0
- package/src/components/pages/FormPage.tsx +34 -0
- package/src/components/pages/Login.tsx +79 -0
- package/src/decorators/form/Form.ts +18 -0
- package/src/decorators/form/FormOptions.ts +8 -0
- package/src/decorators/form/Input.ts +53 -0
- package/src/decorators/form/getFormFields.ts +13 -0
- package/src/decorators/list/Cell.ts +32 -0
- package/src/decorators/list/GetCellFields.ts +13 -0
- package/src/decorators/list/ImageCell.ts +13 -0
- package/src/decorators/list/List.ts +31 -0
- package/src/decorators/list/ListData.ts +7 -0
- package/src/decorators/list/getListFields.ts +10 -0
- package/src/index.ts +28 -10
- package/src/initPanel.ts +4 -12
- package/src/store/store.ts +23 -28
- package/src/styles/counter.scss +42 -0
- package/src/styles/filter-popup.scss +134 -0
- package/src/styles/image-uploader.scss +94 -0
- package/src/styles/index.scss +26 -7
- package/src/styles/layout.scss +1 -6
- package/src/styles/list.scss +175 -7
- package/src/styles/loading-screen.scss +42 -0
- package/src/styles/pagination.scss +66 -0
- package/src/styles/sidebar.scss +64 -0
- package/src/styles/utils/scrollbar.scss +19 -0
- package/src/types/AnyClass.ts +1 -0
- package/src/types/ScreenCreatorData.ts +5 -3
- package/src/types/initPanelOptions.ts +1 -7
- package/src/types/svg.d.ts +5 -0
- package/src/utils/format.ts +7 -0
- package/src/utils/getFields.ts +11 -9
- package/dist/api/crudApi.d.ts +0 -17
- package/dist/components/Form.d.ts +0 -6
- package/dist/components/FormField.d.ts +0 -13
- package/dist/components/list/List.d.ts +0 -10
- package/dist/components/screens/ControllerCreate.d.ts +0 -5
- package/dist/components/screens/ControllerDetails.d.ts +0 -5
- package/dist/components/screens/ControllerEdit.d.ts +0 -5
- package/dist/components/screens/ControllerList.d.ts +0 -5
- package/dist/components/screens/Login.d.ts +0 -2
- package/dist/decorators/Cell.d.ts +0 -9
- package/dist/decorators/Input.d.ts +0 -13
- package/dist/hooks/useScreens.d.ts +0 -2
- package/dist/initPanelOptions.d.ts +0 -8
- package/dist/screens/ControllerCreate.d.ts +0 -5
- package/dist/screens/ControllerDetails.d.ts +0 -5
- package/dist/screens/ControllerEdit.d.ts +0 -5
- package/dist/screens/ControllerList.d.ts +0 -5
- package/dist/screens/Form.d.ts +0 -6
- package/dist/src/api/crudApi.d.ts +0 -6
- package/dist/src/components/Panel.d.ts +0 -9
- package/dist/src/components/layout/Layout.d.ts +0 -11
- package/dist/src/components/layout/SideBar.d.ts +0 -10
- package/dist/src/components/list/List.d.ts +0 -10
- package/dist/src/decorators/Cell.d.ts +0 -10
- package/dist/src/decorators/Crud.d.ts +0 -6
- package/dist/src/index.d.ts +0 -8
- package/dist/src/screens/ControllerCreate.d.ts +0 -5
- package/dist/src/screens/ControllerEdit.d.ts +0 -5
- package/dist/src/screens/ControllerList.d.ts +0 -5
- package/dist/src/screens/Form.d.ts +0 -6
- package/dist/src/store/store.d.ts +0 -19
- package/dist/src/types/Screen.d.ts +0 -4
- package/dist/src/types/ScreenCreatorData.d.ts +0 -8
- package/dist/src/utils/createScreens.d.ts +0 -1
- package/dist/src/utils/getFields.d.ts +0 -2
- package/dist/src/utils/getScreens.d.ts +0 -2
- package/dist/utils/crudScreens.d.ts +0 -2
- package/dist/utils/getScreens.d.ts +0 -2
- package/src/api/AuthApi.ts +0 -14
- package/src/components/Form.tsx +0 -70
- package/src/components/FormField.tsx +0 -60
- package/src/components/list/List.tsx +0 -81
- package/src/components/screens/ControllerCreate.tsx +0 -7
- package/src/components/screens/ControllerDetails.tsx +0 -40
- package/src/components/screens/ControllerEdit.tsx +0 -35
- package/src/components/screens/ControllerList.tsx +0 -45
- package/src/components/screens/Login.tsx +0 -68
- package/src/decorators/Cell.ts +0 -34
- package/src/decorators/Input.ts +0 -50
- package/src/hooks/useScreens.tsx +0 -36
- /package/dist/components/{ErrorBoundary.d.ts → components/ErrorBoundary.d.ts} +0 -0
- /package/dist/components/{ErrorComponent.d.ts → components/ErrorComponent.d.ts} +0 -0
- /package/dist/components/{Label.d.ts → components/Label.d.ts} +0 -0
- /package/src/components/{ErrorBoundary.tsx → components/ErrorBoundary.tsx} +0 -0
- /package/src/components/{ErrorComponent.tsx → components/ErrorComponent.tsx} +0 -0
- /package/src/components/{Label.tsx → components/Label.tsx} +0 -0
@@ -0,0 +1,202 @@
|
|
1
|
+
import React, { useEffect, useMemo, useRef } from 'react';
|
2
|
+
import { ListData } from '../../../decorators/list/ListData';
|
3
|
+
import { CellOptions, StaticSelectFilter } from '../../../decorators/list/Cell';
|
4
|
+
import Select from 'react-select';
|
5
|
+
|
6
|
+
interface FilterPopupProps {
|
7
|
+
isOpen: boolean;
|
8
|
+
onClose: () => void;
|
9
|
+
onApplyFilters: (filters: Record<string, string>) => void;
|
10
|
+
listData: ListData;
|
11
|
+
activeFilters?: Record<string, string>;
|
12
|
+
}
|
13
|
+
|
14
|
+
interface FilterFieldProps {
|
15
|
+
field: CellOptions;
|
16
|
+
value: string;
|
17
|
+
onChange: (value: string) => void;
|
18
|
+
}
|
19
|
+
|
20
|
+
function FilterField({ field, value, onChange }: FilterFieldProps): React.ReactElement {
|
21
|
+
switch (field.filter?.type) {
|
22
|
+
case 'static-select': {
|
23
|
+
const filter = field.filter as StaticSelectFilter;
|
24
|
+
return (
|
25
|
+
<Select
|
26
|
+
id={field.name}
|
27
|
+
menuPortalTarget={document.body}
|
28
|
+
styles={{
|
29
|
+
control: (baseStyles, state) => ({
|
30
|
+
...baseStyles,
|
31
|
+
backgroundColor: '#1f2937',
|
32
|
+
borderColor: state.isFocused ? '#6366f1' : '#374151',
|
33
|
+
boxShadow: state.isFocused ? '0 0 0 1px #6366f1' : 'none',
|
34
|
+
'&:hover': {
|
35
|
+
borderColor: '#6366f1',
|
36
|
+
},
|
37
|
+
borderRadius: '6px',
|
38
|
+
padding: '2px',
|
39
|
+
color: 'white',
|
40
|
+
}),
|
41
|
+
option: (baseStyles, state) => ({
|
42
|
+
...baseStyles,
|
43
|
+
backgroundColor: state.isSelected
|
44
|
+
? '#6366f1'
|
45
|
+
: state.isFocused
|
46
|
+
? '#374151'
|
47
|
+
: '#1f2937',
|
48
|
+
color: 'white',
|
49
|
+
'&:active': {
|
50
|
+
backgroundColor: '#6366f1',
|
51
|
+
},
|
52
|
+
'&:hover': {
|
53
|
+
backgroundColor: '#374151',
|
54
|
+
},
|
55
|
+
cursor: 'pointer',
|
56
|
+
}),
|
57
|
+
input: baseStyles => ({
|
58
|
+
...baseStyles,
|
59
|
+
color: 'white',
|
60
|
+
}),
|
61
|
+
placeholder: baseStyles => ({
|
62
|
+
...baseStyles,
|
63
|
+
color: '#9ca3af',
|
64
|
+
}),
|
65
|
+
singleValue: baseStyles => ({
|
66
|
+
...baseStyles,
|
67
|
+
color: 'white',
|
68
|
+
}),
|
69
|
+
menuPortal: baseStyles => ({
|
70
|
+
...baseStyles,
|
71
|
+
zIndex: 9999,
|
72
|
+
}),
|
73
|
+
menu: baseStyles => ({
|
74
|
+
...baseStyles,
|
75
|
+
backgroundColor: '#1f2937',
|
76
|
+
border: '1px solid #374151',
|
77
|
+
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
78
|
+
}),
|
79
|
+
menuList: baseStyles => ({
|
80
|
+
...baseStyles,
|
81
|
+
padding: '4px',
|
82
|
+
}),
|
83
|
+
dropdownIndicator: baseStyles => ({
|
84
|
+
...baseStyles,
|
85
|
+
color: '#9ca3af',
|
86
|
+
'&:hover': {
|
87
|
+
color: '#6366f1',
|
88
|
+
},
|
89
|
+
}),
|
90
|
+
clearIndicator: baseStyles => ({
|
91
|
+
...baseStyles,
|
92
|
+
color: '#9ca3af',
|
93
|
+
'&:hover': {
|
94
|
+
color: '#6366f1',
|
95
|
+
},
|
96
|
+
}),
|
97
|
+
}}
|
98
|
+
value={
|
99
|
+
value
|
100
|
+
? {
|
101
|
+
value: value,
|
102
|
+
label: filter.options.find(opt => opt.value === value)?.label || value,
|
103
|
+
}
|
104
|
+
: null
|
105
|
+
}
|
106
|
+
onChange={option => onChange(option?.value || '')}
|
107
|
+
options={filter.options.map(opt => ({
|
108
|
+
value: opt.value,
|
109
|
+
label: opt.label,
|
110
|
+
}))}
|
111
|
+
placeholder={`Filter by ${field.title || field.name}`}
|
112
|
+
isClearable
|
113
|
+
/>
|
114
|
+
);
|
115
|
+
}
|
116
|
+
default:
|
117
|
+
return (
|
118
|
+
<input
|
119
|
+
type={field.type === 'number' ? 'number' : 'text'}
|
120
|
+
id={field.name}
|
121
|
+
value={value || ''}
|
122
|
+
onChange={e => onChange(e.target.value)}
|
123
|
+
placeholder={`Filter by ${field.title || field.name}`}
|
124
|
+
/>
|
125
|
+
);
|
126
|
+
}
|
127
|
+
}
|
128
|
+
|
129
|
+
export function FilterPopup({
|
130
|
+
isOpen,
|
131
|
+
onClose,
|
132
|
+
onApplyFilters,
|
133
|
+
listData,
|
134
|
+
activeFilters,
|
135
|
+
}: FilterPopupProps): React.ReactElement | null {
|
136
|
+
const [filters, setFilters] = React.useState<Record<string, any>>(activeFilters ?? {});
|
137
|
+
const popupRef = useRef<HTMLDivElement>(null);
|
138
|
+
const fields = useMemo(() => listData.cells.filter(cell => !!cell.filter), [listData.cells]);
|
139
|
+
|
140
|
+
useEffect(() => {
|
141
|
+
const handleClickOutside = (event: MouseEvent) => {
|
142
|
+
if (popupRef.current && !popupRef.current.contains(event.target as Node)) {
|
143
|
+
onClose();
|
144
|
+
}
|
145
|
+
};
|
146
|
+
|
147
|
+
if (isOpen) {
|
148
|
+
document.addEventListener('mousedown', handleClickOutside);
|
149
|
+
}
|
150
|
+
|
151
|
+
return () => {
|
152
|
+
document.removeEventListener('mousedown', handleClickOutside);
|
153
|
+
};
|
154
|
+
}, [isOpen, onClose]);
|
155
|
+
|
156
|
+
if (!isOpen) return null;
|
157
|
+
|
158
|
+
const handleFilterChange = (fieldName: string, value: any) => {
|
159
|
+
setFilters(prev => ({
|
160
|
+
...prev,
|
161
|
+
[fieldName]: value,
|
162
|
+
}));
|
163
|
+
};
|
164
|
+
|
165
|
+
const handleApply = () => {
|
166
|
+
onApplyFilters(filters);
|
167
|
+
onClose();
|
168
|
+
};
|
169
|
+
|
170
|
+
return (
|
171
|
+
<div className="filter-popup-overlay">
|
172
|
+
<div ref={popupRef} className="filter-popup">
|
173
|
+
<div className="filter-popup-header">
|
174
|
+
<h3>Filter</h3>
|
175
|
+
<button onClick={onClose} className="close-button">
|
176
|
+
×
|
177
|
+
</button>
|
178
|
+
</div>
|
179
|
+
<div className="filter-popup-content">
|
180
|
+
{fields.map((field: CellOptions) => (
|
181
|
+
<div key={field.name} className="filter-field">
|
182
|
+
<label htmlFor={field.name}>{field.title || field.name}</label>
|
183
|
+
<FilterField
|
184
|
+
field={field}
|
185
|
+
value={filters[field.name || '']}
|
186
|
+
onChange={value => handleFilterChange(field.name || '', value)}
|
187
|
+
/>
|
188
|
+
</div>
|
189
|
+
))}
|
190
|
+
</div>
|
191
|
+
<div className="filter-popup-footer">
|
192
|
+
<button onClick={onClose} className="cancel-button">
|
193
|
+
Cancel
|
194
|
+
</button>
|
195
|
+
<button onClick={handleApply} className="apply-button">
|
196
|
+
Apply Filters
|
197
|
+
</button>
|
198
|
+
</div>
|
199
|
+
</div>
|
200
|
+
</div>
|
201
|
+
);
|
202
|
+
}
|
@@ -0,0 +1,178 @@
|
|
1
|
+
import React, { useMemo, useCallback, useEffect, useState } from 'react';
|
2
|
+
import { Link, useParams, useNavigate } from 'react-router';
|
3
|
+
import { Datagrid } from './Datagrid';
|
4
|
+
import { ErrorComponent } from '../ErrorComponent';
|
5
|
+
import { LoadingScreen } from '../LoadingScreen';
|
6
|
+
import { AnyClass } from '../../../types/AnyClass';
|
7
|
+
import { getListFields } from '../../../decorators/list/getListFields';
|
8
|
+
import { Pagination } from './Pagination';
|
9
|
+
import { ListData } from '../../../decorators/list/ListData';
|
10
|
+
import CreateIcon from '../../../assets/icons/svg/create.svg';
|
11
|
+
import FilterIcon from '../../../assets/icons/svg/filter.svg';
|
12
|
+
import { FilterPopup } from './FilterPopup';
|
13
|
+
|
14
|
+
export interface GetDataParams {
|
15
|
+
page?: number;
|
16
|
+
limit?: number;
|
17
|
+
filters?: Record<string, any>;
|
18
|
+
}
|
19
|
+
|
20
|
+
export interface PaginatedResponse<T> {
|
21
|
+
data: T[];
|
22
|
+
total: number;
|
23
|
+
page: number;
|
24
|
+
limit: number;
|
25
|
+
}
|
26
|
+
|
27
|
+
export type GetDataForList<T> = (params: GetDataParams) => Promise<PaginatedResponse<T>>;
|
28
|
+
|
29
|
+
const ListHeader = ({
|
30
|
+
listData,
|
31
|
+
filtered,
|
32
|
+
onFilterClick,
|
33
|
+
customHeader,
|
34
|
+
}: {
|
35
|
+
listData: ListData;
|
36
|
+
filtered: boolean;
|
37
|
+
onFilterClick: () => void;
|
38
|
+
customHeader?: React.ReactNode;
|
39
|
+
}) => {
|
40
|
+
const fields = useMemo(() => listData.cells.filter(cell => !!cell.filter), [listData.cells]);
|
41
|
+
|
42
|
+
const header = listData.list?.headers;
|
43
|
+
return (
|
44
|
+
<div className="list-header">
|
45
|
+
<div className="header-title">{header?.title || 'List'}</div>
|
46
|
+
{customHeader && <div className="header-custom">{customHeader}</div>}
|
47
|
+
<div className="header-actions">
|
48
|
+
{!!fields.length && (
|
49
|
+
<button onClick={onFilterClick} className="filter-button">
|
50
|
+
<FilterIcon className={`icon icon-filter ${filtered ? 'active' : ''}`} />
|
51
|
+
Filter
|
52
|
+
</button>
|
53
|
+
)}
|
54
|
+
{header?.create && (
|
55
|
+
<Link to={header.create.path} className="create-button">
|
56
|
+
<CreateIcon className="icon icon-create" />
|
57
|
+
{header.create.label}
|
58
|
+
</Link>
|
59
|
+
)}
|
60
|
+
</div>
|
61
|
+
</div>
|
62
|
+
);
|
63
|
+
};
|
64
|
+
|
65
|
+
export function ListPage<T extends AnyClass & { id: string }>({
|
66
|
+
model,
|
67
|
+
getData,
|
68
|
+
onRemoveItem,
|
69
|
+
customHeader,
|
70
|
+
}: {
|
71
|
+
model: T;
|
72
|
+
getData: GetDataForList<T>;
|
73
|
+
customHeader?: React.ReactNode;
|
74
|
+
onRemoveItem?: (item: T) => Promise<void>;
|
75
|
+
}) {
|
76
|
+
const [loading, setLoading] = useState(true);
|
77
|
+
const [pagination, setPagination] = useState({ total: 0, page: 0, limit: 0 });
|
78
|
+
const [data, setData] = useState<any>(null);
|
79
|
+
const [error, setError] = useState<unknown>(null);
|
80
|
+
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
81
|
+
const [activeFilters, setActiveFilters] = useState<Record<string, string>>();
|
82
|
+
const listData = useMemo(() => getListFields(model), [model]);
|
83
|
+
const params = useParams();
|
84
|
+
const navigate = useNavigate();
|
85
|
+
|
86
|
+
const fetchData = useCallback(
|
87
|
+
async (page: number, filters?: Record<string, string>) => {
|
88
|
+
setLoading(true);
|
89
|
+
try {
|
90
|
+
const result = await getData({ page, filters: filters ?? activeFilters ?? {} });
|
91
|
+
setData(result.data);
|
92
|
+
setPagination({
|
93
|
+
total: result.total,
|
94
|
+
page: result.page,
|
95
|
+
limit: result.limit,
|
96
|
+
});
|
97
|
+
} catch (e) {
|
98
|
+
setError(e);
|
99
|
+
console.error(e);
|
100
|
+
} finally {
|
101
|
+
setLoading(false);
|
102
|
+
}
|
103
|
+
},
|
104
|
+
[getData, activeFilters]
|
105
|
+
);
|
106
|
+
|
107
|
+
useEffect(() => {
|
108
|
+
const searchParams = new URLSearchParams(location.search);
|
109
|
+
const filtersFromUrl: Record<string, string> = {};
|
110
|
+
searchParams.forEach((value, key) => {
|
111
|
+
filtersFromUrl[key] = value;
|
112
|
+
});
|
113
|
+
setActiveFilters(filtersFromUrl);
|
114
|
+
}, [location.search]);
|
115
|
+
|
116
|
+
useEffect(() => {
|
117
|
+
if (activeFilters) {
|
118
|
+
fetchData(parseInt(params.page as string) || 1, activeFilters);
|
119
|
+
}
|
120
|
+
}, [fetchData, params.page, activeFilters]);
|
121
|
+
|
122
|
+
const handleFilterApply = (filters: Record<string, any>) => {
|
123
|
+
setActiveFilters(filters);
|
124
|
+
|
125
|
+
// Convert filters to URLSearchParams
|
126
|
+
const searchParams = new URLSearchParams();
|
127
|
+
Object.entries(filters).forEach(([key, value]) => {
|
128
|
+
if (value !== undefined && value !== null && value !== '') {
|
129
|
+
searchParams.append(key, String(value));
|
130
|
+
}
|
131
|
+
});
|
132
|
+
const queryString = searchParams.toString();
|
133
|
+
const newUrl = `${location.pathname}${queryString ? `?${queryString}` : ''}`;
|
134
|
+
navigate(newUrl);
|
135
|
+
fetchData(1, filters); // Reset to first page when filters change
|
136
|
+
};
|
137
|
+
|
138
|
+
if (loading) return <LoadingScreen />;
|
139
|
+
if (error) return <ErrorComponent error={error} />;
|
140
|
+
|
141
|
+
return (
|
142
|
+
<div className="list">
|
143
|
+
<ListHeader
|
144
|
+
listData={listData}
|
145
|
+
filtered={!!(activeFilters && !!Object.keys(activeFilters).length)}
|
146
|
+
onFilterClick={() => setIsFilterOpen(true)}
|
147
|
+
customHeader={customHeader}
|
148
|
+
/>
|
149
|
+
<Datagrid
|
150
|
+
listData={listData}
|
151
|
+
data={data}
|
152
|
+
onRemoveItem={async (item: T) => {
|
153
|
+
if (onRemoveItem) {
|
154
|
+
alert({
|
155
|
+
title: 'Are you sure you want to delete this item?',
|
156
|
+
message: 'This action cannot be undone.',
|
157
|
+
onConfirm: async () => {
|
158
|
+
await onRemoveItem(item);
|
159
|
+
setData(data.filter((d: T) => d.id !== item.id));
|
160
|
+
await fetchData(pagination.page);
|
161
|
+
},
|
162
|
+
});
|
163
|
+
}
|
164
|
+
}}
|
165
|
+
/>
|
166
|
+
<div className="list-footer">
|
167
|
+
<Pagination pagination={pagination} onPageChange={fetchData} />
|
168
|
+
</div>
|
169
|
+
<FilterPopup
|
170
|
+
isOpen={isFilterOpen}
|
171
|
+
activeFilters={activeFilters}
|
172
|
+
onClose={() => setIsFilterOpen(false)}
|
173
|
+
onApplyFilters={handleFilterApply}
|
174
|
+
listData={listData}
|
175
|
+
/>
|
176
|
+
</div>
|
177
|
+
);
|
178
|
+
}
|
@@ -0,0 +1,110 @@
|
|
1
|
+
import React from 'react';
|
2
|
+
|
3
|
+
interface PaginationProps {
|
4
|
+
pagination: {
|
5
|
+
total: number;
|
6
|
+
page: number;
|
7
|
+
limit: number;
|
8
|
+
};
|
9
|
+
onPageChange: (page: number) => void;
|
10
|
+
}
|
11
|
+
|
12
|
+
export function Pagination({ pagination, onPageChange }: PaginationProps) {
|
13
|
+
const { total, page, limit } = pagination;
|
14
|
+
const totalPages = Math.floor(total / limit);
|
15
|
+
|
16
|
+
if (totalPages <= 1) return null;
|
17
|
+
|
18
|
+
const renderPageNumbers = () => {
|
19
|
+
const pages = [];
|
20
|
+
const range = 2; // Number of pages to show before and after current page
|
21
|
+
|
22
|
+
// Always show first 2 pages
|
23
|
+
for (let i = 1; i <= Math.min(2, totalPages); i++) {
|
24
|
+
pages.push(
|
25
|
+
<button
|
26
|
+
key={i}
|
27
|
+
onClick={() => onPageChange(i)}
|
28
|
+
className={`pagination-item ${page === i ? 'active' : ''}`}
|
29
|
+
disabled={page === i}
|
30
|
+
>
|
31
|
+
{i}
|
32
|
+
</button>
|
33
|
+
);
|
34
|
+
}
|
35
|
+
|
36
|
+
// Add ellipsis if needed
|
37
|
+
if (page - range > 3) {
|
38
|
+
pages.push(
|
39
|
+
<span key="ellipsis1" className="pagination-ellipsis">
|
40
|
+
...
|
41
|
+
</span>
|
42
|
+
);
|
43
|
+
}
|
44
|
+
|
45
|
+
// Show pages around current page
|
46
|
+
for (let i = Math.max(3, page - range); i <= Math.min(totalPages - 2, page + range); i++) {
|
47
|
+
if (i > 2 && i < totalPages - 1) {
|
48
|
+
pages.push(
|
49
|
+
<button
|
50
|
+
key={i}
|
51
|
+
onClick={() => onPageChange(i)}
|
52
|
+
className={`pagination-item ${page === i ? 'active' : ''}`}
|
53
|
+
disabled={page === i}
|
54
|
+
>
|
55
|
+
{i}
|
56
|
+
</button>
|
57
|
+
);
|
58
|
+
}
|
59
|
+
}
|
60
|
+
|
61
|
+
// Add ellipsis if needed
|
62
|
+
if (page + range < totalPages - 2) {
|
63
|
+
pages.push(
|
64
|
+
<span key="ellipsis2" className="pagination-ellipsis">
|
65
|
+
...
|
66
|
+
</span>
|
67
|
+
);
|
68
|
+
}
|
69
|
+
|
70
|
+
// Always show last 2 pages
|
71
|
+
for (let i = Math.max(totalPages - 1, 3); i <= totalPages; i++) {
|
72
|
+
if (i > 2) {
|
73
|
+
pages.push(
|
74
|
+
<button
|
75
|
+
key={i}
|
76
|
+
onClick={() => onPageChange(i)}
|
77
|
+
className={`pagination-item ${page === i ? 'active' : ''}`}
|
78
|
+
disabled={page === i}
|
79
|
+
>
|
80
|
+
{i}
|
81
|
+
</button>
|
82
|
+
);
|
83
|
+
}
|
84
|
+
}
|
85
|
+
|
86
|
+
return pages;
|
87
|
+
};
|
88
|
+
|
89
|
+
return (
|
90
|
+
<div className="pagination">
|
91
|
+
<button
|
92
|
+
onClick={() => onPageChange(page - 1)}
|
93
|
+
className={`pagination-item ${page === 1 ? 'disabled' : ''}`}
|
94
|
+
disabled={page === 1}
|
95
|
+
aria-disabled={page === 1}
|
96
|
+
>
|
97
|
+
Previous
|
98
|
+
</button>
|
99
|
+
{renderPageNumbers()}
|
100
|
+
<button
|
101
|
+
onClick={() => onPageChange(page + 1)}
|
102
|
+
className={`pagination-item ${page === totalPages ? 'disabled' : ''}`}
|
103
|
+
disabled={page === totalPages}
|
104
|
+
aria-disabled={page === totalPages}
|
105
|
+
>
|
106
|
+
Next
|
107
|
+
</button>
|
108
|
+
</div>
|
109
|
+
);
|
110
|
+
}
|
@@ -0,0 +1 @@
|
|
1
|
+
|
@@ -8,15 +8,18 @@ export function Layout<IconType>({
|
|
8
8
|
children,
|
9
9
|
menu,
|
10
10
|
getIcons,
|
11
|
+
logout,
|
11
12
|
}: {
|
12
13
|
children?: React.ReactNode;
|
13
14
|
menu?: (screens: Record<string, ScreenCreatorData>) => { name: string; path: string; iconType: IconType }[];
|
14
15
|
getIcons?: (iconType: IconType) => React.ReactNode;
|
16
|
+
logout?: () => void;
|
15
17
|
}) {
|
16
18
|
const { user, screenPaths } = useAppStore((s) => ({
|
17
19
|
user: s.user,
|
18
20
|
screenPaths: s.screenPaths,
|
19
21
|
}));
|
22
|
+
const data = useAppStore();
|
20
23
|
const navigate = useNavigate();
|
21
24
|
if (!user) {
|
22
25
|
navigate(screenPaths.login);
|
@@ -24,7 +27,11 @@ export function Layout<IconType>({
|
|
24
27
|
|
25
28
|
return (
|
26
29
|
<div className="layout">
|
27
|
-
<SideBar
|
30
|
+
<SideBar onLogout={() => {
|
31
|
+
if (logout) {
|
32
|
+
logout();
|
33
|
+
}
|
34
|
+
}} menu={menu} getIcons={getIcons} />
|
28
35
|
<main className="content">{children}</main>
|
29
36
|
</div>
|
30
37
|
);
|
@@ -1,42 +1,114 @@
|
|
1
|
-
import React, { useState } from
|
2
|
-
import { Link } from
|
3
|
-
import { ScreenCreatorData } from
|
4
|
-
import { useAppStore } from
|
1
|
+
import React, { useState } from 'react';
|
2
|
+
import { Link, useLocation, useNavigate } from 'react-router';
|
3
|
+
import { ScreenCreatorData } from '../../types/ScreenCreatorData';
|
4
|
+
import { useAppStore } from '../../store/store';
|
5
5
|
|
6
6
|
type GetMenuFunction<IconType> = (
|
7
|
-
|
7
|
+
screens: Record<string, ScreenCreatorData>
|
8
8
|
) => { name: string; path: string; iconType: IconType }[];
|
9
9
|
|
10
10
|
type GetIconsFunction<IconType> = (iconType: IconType) => React.ReactNode;
|
11
11
|
|
12
12
|
export function SideBar<IconType>({
|
13
|
-
|
14
|
-
|
13
|
+
menu,
|
14
|
+
getIcons,
|
15
|
+
onLogout,
|
15
16
|
}: {
|
16
|
-
|
17
|
-
|
17
|
+
menu?: GetMenuFunction<IconType>;
|
18
|
+
getIcons?: GetIconsFunction<IconType>;
|
19
|
+
onLogout?: () => void;
|
18
20
|
}) {
|
19
|
-
|
20
|
-
|
21
|
+
const { screens, screenPaths } = useAppStore(s => ({
|
22
|
+
screens: s.screens ?? {},
|
23
|
+
screenPaths: s.screenPaths ?? {},
|
24
|
+
}));
|
25
|
+
const [isOpen, setIsOpen] = useState(true);
|
26
|
+
const location = useLocation();
|
27
|
+
const navigate = useNavigate();
|
21
28
|
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
29
|
+
// Helper function to determine if a link is active
|
30
|
+
const isActiveLink = (path: string) => {
|
31
|
+
// For root path, we need exact match
|
32
|
+
if (path === '/') {
|
33
|
+
return location.pathname === path;
|
34
|
+
}
|
35
|
+
|
36
|
+
// Normalize paths by removing leading/trailing slashes for comparison
|
37
|
+
const normalizedPath = path.replace(/^\/+|\/+$/g, '');
|
38
|
+
const normalizedLocation = location.pathname.replace(/^\/+|\/+$/g, '');
|
39
|
+
|
40
|
+
// Check if the current path matches the link path
|
41
|
+
return (
|
42
|
+
normalizedLocation === normalizedPath || normalizedLocation.startsWith(`${normalizedPath}/`)
|
43
|
+
);
|
44
|
+
};
|
45
|
+
|
46
|
+
return (
|
47
|
+
<div className={`sidebar ${isOpen ? 'open' : 'closed'}`}>
|
48
|
+
<button
|
49
|
+
className="toggle-button"
|
50
|
+
onClick={() => setIsOpen(!isOpen)}
|
51
|
+
aria-label={isOpen ? 'Collapse sidebar' : 'Expand sidebar'}
|
52
|
+
aria-expanded={isOpen}
|
53
|
+
>
|
54
|
+
{isOpen ? '<' : '>'}
|
55
|
+
</button>
|
56
|
+
<nav className="nav-links">
|
57
|
+
{menu?.(screens).map((item, index) => (
|
58
|
+
<Link
|
59
|
+
key={index}
|
60
|
+
to={item.path}
|
61
|
+
className={`nav-link ${isActiveLink(item.path) ? 'active' : ''}`}
|
62
|
+
aria-current={isActiveLink(item.path) ? 'page' : undefined}
|
63
|
+
>
|
64
|
+
<span className={'nav-links-icon'}>{getIcons?.(item.iconType)}</span>
|
65
|
+
{isOpen ? <span>{item.name}</span> : null}
|
66
|
+
</Link>
|
67
|
+
))}
|
68
|
+
</nav>
|
69
|
+
{onLogout && (
|
70
|
+
<div className="sidebar-footer">
|
71
|
+
<button
|
72
|
+
className="logout-button"
|
73
|
+
onClick={() => {
|
74
|
+
if (onLogout) {
|
75
|
+
onLogout();
|
76
|
+
navigate(screenPaths.login);
|
77
|
+
}
|
78
|
+
}}
|
79
|
+
aria-label="Logout"
|
80
|
+
>
|
81
|
+
<span className="nav-links-icon">
|
82
|
+
{
|
83
|
+
/*TODO: remove*/
|
84
|
+
<svg
|
85
|
+
width="16"
|
86
|
+
height="16"
|
87
|
+
viewBox="0 0 16 16"
|
88
|
+
fill="none"
|
89
|
+
xmlns="http://www.w3.org/2000/svg"
|
90
|
+
>
|
91
|
+
<path
|
92
|
+
d="M6 12H2V4H6"
|
93
|
+
stroke="currentColor"
|
94
|
+
strokeWidth="1.5"
|
95
|
+
strokeLinecap="round"
|
96
|
+
strokeLinejoin="round"
|
97
|
+
/>
|
98
|
+
<path
|
99
|
+
d="M10 8L14 4M14 4L10 0M14 4H6"
|
100
|
+
stroke="currentColor"
|
101
|
+
strokeWidth="1.5"
|
102
|
+
strokeLinecap="round"
|
103
|
+
strokeLinejoin="round"
|
104
|
+
/>
|
105
|
+
</svg>
|
106
|
+
}
|
107
|
+
</span>
|
108
|
+
{isOpen ? <span>Logout</span> : null}
|
109
|
+
</button>
|
110
|
+
</div>
|
111
|
+
)}
|
112
|
+
</div>
|
113
|
+
);
|
42
114
|
}
|