ui-soxo-bootstrap-core 2.6.39 → 2.6.40-dev.1

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.
Files changed (23) hide show
  1. package/.github/workflows/npm-publish.yml +40 -33
  2. package/DEVELOPER_GUIDE.md +38 -9
  3. package/core/components/index.js +2 -11
  4. package/core/components/landing-api/landing-api.js +165 -5
  5. package/core/components/license-management/license-alert.js +97 -0
  6. package/core/lib/components/global-header/global-header.js +20 -77
  7. package/core/lib/components/index.js +2 -2
  8. package/core/lib/elements/basic/dragabble-wrapper/draggable-wrapper.js +91 -24
  9. package/core/lib/modules/generic/generic-list/ExportReactCSV.js +334 -8
  10. package/core/lib/modules/generic/generic-list/generic-list.scss +34 -0
  11. package/core/models/core-scripts/core-scripts.js +22 -1
  12. package/core/models/menus/menus.js +29 -1
  13. package/core/models/roles/components/role-add/menu-label.js +14 -0
  14. package/core/models/roles/components/role-add/menu-tree.js +127 -0
  15. package/core/models/roles/components/role-add/role-add.js +44 -167
  16. package/core/models/roles/components/role-list/role-list.js +20 -0
  17. package/core/models/users/components/assign-role/assign-role.js +23 -8
  18. package/core/modules/reporting/components/reporting-dashboard/display-columns/display-cell-renderer.js +202 -5
  19. package/core/modules/reporting/components/reporting-dashboard/display-columns/display-cell-renderer.test.js +73 -0
  20. package/core/modules/reporting/components/reporting-dashboard/reporting-dashboard.js +25 -5
  21. package/core/modules/reporting/components/reporting-dashboard/reporting-dashboard.scss +1 -0
  22. package/core/modules/reporting/components/reporting-dashboard/reporting-table.js +519 -0
  23. package/package.json +1 -1
@@ -1,15 +1,14 @@
1
1
  import React, { useState, useEffect, useContext } from 'react';
2
2
  import { useLocation, useParams } from 'react-router-dom';
3
- import { Skeleton, Typography, message, Form, Input, Collapse, Checkbox, Tag } from 'antd';
3
+ import { Skeleton, Typography, message, Form, Input } from 'antd';
4
4
  import { GlobalContext } from './../../../../lib';
5
- import { Button } from './../../../../lib';
6
5
  import { ModelsAPI, PagesAPI, RolesAPI, MenusAPI } from '../../..';
6
+ import MenuTree from './menu-tree';
7
7
  import './role-add.scss';
8
8
 
9
9
  const { Title } = Typography;
10
- const { Panel } = Collapse;
11
10
 
12
- const RoleAdd = ({ model, callback, edit, formContent = {}, match, additional_queries = [] }) => {
11
+ const RoleAdd = ({ model, callback, edit, formContent = {}, match, additional_queries = [], onSubmittingChange, formRef }) => {
13
12
  let mode = 'Add';
14
13
  if (formContent.id) mode = 'Edit';
15
14
  else if (formContent.copy) mode = 'copy';
@@ -31,8 +30,10 @@ const RoleAdd = ({ model, callback, edit, formContent = {}, match, additional_qu
31
30
 
32
31
  const isEdit = !!(formContent.id || formContent.copy);
33
32
  const [initialLoading, setInitialLoading] = useState(isEdit);
34
- const [loading, setLoading] = useState(false);
35
33
  const [form] = Form.useForm();
34
+
35
+ // expose the form instance so the Drawer footer Save button can submit it
36
+ if (formRef) formRef.current = form;
36
37
  const [pages, setPages] = useState([]);
37
38
  const [models, setModels] = useState([]);
38
39
  const [menuList, setMenuList] = useState([]);
@@ -43,6 +44,9 @@ const RoleAdd = ({ model, callback, edit, formContent = {}, match, additional_qu
43
44
  // Selected menus (array of IDs)
44
45
  const [selectedMenus, setSelectedMenus] = useState([]);
45
46
 
47
+ // Search term for filtering the menu list
48
+ const [menuSearch, setMenuSearch] = useState('');
49
+
46
50
  const location = useLocation();
47
51
  const query = new URLSearchParams(location.search);
48
52
  let step = parseInt(query.get('step')) || 1;
@@ -97,40 +101,28 @@ const RoleAdd = ({ model, callback, edit, formContent = {}, match, additional_qu
97
101
  setSelectedMenus((prev) => (checked ? [...prev, id] : prev.filter((m) => m !== id)));
98
102
  };
99
103
 
100
- // Submit handler
101
- // const onSubmit = (values) => {
102
- // setLoading(true);
103
-
104
- // const payload = {
105
- // ...values,
106
- // menu_ids
107
- // : selectedMenus,
108
- // attributes: JSON.stringify(values.attributes || {}),
109
- // };
110
-
111
- // if (formContent.id) {
112
- // RolesAPI.updateRole({ id: formContent.id, formBody: payload })
113
- // .then(() => {
114
- // message.success('Role Updated');
115
- // setLoading(false);
116
- // callback();
117
- // })
118
- // .catch(() => setLoading(false));
119
- // } else {
120
- // additional_queries.forEach(({ field, value }) => {
121
- // payload[field] = value;
122
- // });
123
- // RolesAPI.createRole(payload)
124
- // .then(() => {
125
- // message.success('Role Added');
126
- // setLoading(false);
127
- // callback();
128
- // })
129
- // .catch(() => setLoading(false));
130
- // }
131
- // };
104
+ // Recursively filter menus by caption. A menu is kept if its caption matches
105
+ // the search term, or if any of its descendants match (ancestors are kept so
106
+ // the matching child stays reachable).
107
+ const filterMenus = (menus, term) => {
108
+ if (!term) return menus;
109
+ const lower = term.toLowerCase();
110
+
111
+ return menus.reduce((acc, menu) => {
112
+ const children = filterMenus(menu.sub_menus || [], term);
113
+ const selfMatch = (menu.caption || '').toLowerCase().includes(lower);
114
+
115
+ if (selfMatch || children.length > 0) {
116
+ acc.push({ ...menu, sub_menus: children });
117
+ }
118
+ return acc;
119
+ }, []);
120
+ };
121
+
122
+ const filteredMenuList = filterMenus(menuList, menuSearch);
123
+
132
124
  const onSubmit = async (values) => {
133
- setLoading(true);
125
+ onSubmittingChange?.(true);
134
126
 
135
127
  // Find menus that were originally selected but now deselected
136
128
  const deselectedMenus = originalMenus.filter((id) => !selectedMenus.includes(id));
@@ -159,7 +151,7 @@ const RoleAdd = ({ model, callback, edit, formContent = {}, match, additional_qu
159
151
  } catch (err) {
160
152
  // message.error('Something went wrong');
161
153
  } finally {
162
- setLoading(false);
154
+ onSubmittingChange?.(false);
163
155
  }
164
156
  };
165
157
 
@@ -194,6 +186,7 @@ const RoleAdd = ({ model, callback, edit, formContent = {}, match, additional_qu
194
186
  display: 'flex',
195
187
  justifyContent: 'space-between',
196
188
  alignItems: 'center',
189
+ gap: 16,
197
190
  marginBottom: 16,
198
191
  }}
199
192
  >
@@ -204,14 +197,20 @@ const RoleAdd = ({ model, callback, edit, formContent = {}, match, additional_qu
204
197
  <p style={{ color: '#999', marginBottom: 0 }}>Choose menus and set permissions</p>
205
198
  </div>
206
199
 
207
- <Form.Item style={{ marginBottom: 0 }}>
208
- <Button loading={loading} htmlType="submit" type="primary">
209
- Save
210
- </Button>
211
- </Form.Item>
200
+ <Input.Search
201
+ allowClear
202
+ placeholder="Search menus"
203
+ value={menuSearch}
204
+ onChange={(e) => setMenuSearch(e.target.value)}
205
+ style={{ maxWidth: 260 }}
206
+ />
212
207
  </div>
213
208
 
214
- <MenuTree menus={menuList} selectedMenus={selectedMenus} toggleMenu={toggleMenu} />
209
+ {filteredMenuList.length > 0 ? (
210
+ <MenuTree menus={filteredMenuList} selectedMenus={selectedMenus} toggleMenu={toggleMenu} searchActive={!!menuSearch} />
211
+ ) : (
212
+ <p style={{ color: '#999', textAlign: 'center', padding: '16px 0' }}>No menus found</p>
213
+ )}
215
214
  </div>
216
215
  )}
217
216
  </Form>
@@ -221,125 +220,3 @@ const RoleAdd = ({ model, callback, edit, formContent = {}, match, additional_qu
221
220
  };
222
221
 
223
222
  export default RoleAdd;
224
-
225
- // ------------------------
226
- // Recursive Nested Menu Component
227
- // ------------------------
228
- // ------------------------
229
- // Recursive Nested Menu Component
230
- // ------------------------
231
- const MenuTree = ({ menus, selectedMenus, toggleMenu, parentId = null }) => {
232
- // Helper: check if parent should be checked
233
- const isParentChecked = (menu) => {
234
- if (!menu.sub_menus || menu.sub_menus.length === 0) {
235
- return selectedMenus.includes(menu.id);
236
- }
237
- const allChildIds = menu.sub_menus.map((c) => c.id);
238
- return allChildIds.every((id) => selectedMenus.includes(id));
239
- };
240
-
241
- // Helper: check if parent is indeterminate
242
- const isParentIndeterminate = (menu) => {
243
- if (!menu.sub_menus || menu.sub_menus.length === 0) return false;
244
- const allChildIds = menu.sub_menus.map((c) => c.id);
245
- const checkedCount = allChildIds.filter((id) => selectedMenus.includes(id)).length;
246
- return checkedCount > 0 && checkedCount < allChildIds.length;
247
- };
248
-
249
- return (
250
- <>
251
- {menus.map((menu) => {
252
- const children = menu.sub_menus || [];
253
- const parentChecked = isParentChecked(menu);
254
- const parentIndeterminate = isParentIndeterminate(menu);
255
-
256
- const onParentChange = (checked) => {
257
- toggleMenu(menu.id, checked);
258
- // toggle children recursively
259
- children.forEach((c) => toggleMenuRecursive(c, checked));
260
- };
261
-
262
- const toggleMenuRecursive = (menu, checked) => {
263
- toggleMenu(menu.id, checked);
264
- if (menu.sub_menus && menu.sub_menus.length > 0) {
265
- menu.sub_menus.forEach((c) => toggleMenuRecursive(c, checked));
266
- }
267
- };
268
-
269
- if (children.length === 0) {
270
- return (
271
- <div
272
- style={{
273
- justifyContent: 'space-between',
274
- display: 'flex',
275
- alignItems: 'center',
276
- border: '1px solid rgba(198, 195, 195, 0.85)',
277
- // borderRadius: 6,
278
- padding: '12px 16px',
279
- marginBottom: 6,
280
- background: '#fff',
281
- }}
282
- >
283
- <div
284
- key={menu.id}
285
- style={{
286
- display: 'flex',
287
- alignItems: 'center',
288
- gap: 8,
289
- }}
290
- >
291
- <Checkbox
292
- checked={selectedMenus.includes(menu.id)}
293
- onChange={(e) => {
294
- const checked = e.target.checked;
295
-
296
- toggleMenu(menu.id, checked);
297
-
298
- // FORCE parent selection
299
- if (checked && parentId) {
300
- toggleMenu(parentId, true);
301
- }
302
- }}
303
- />
304
- <span>{menu.caption}</span>
305
- </div>
306
- <Tag color={menu.is_visible === true ? 'green' : 'blue'}>{menu.is_visible === true ? 'VISIBLE' : 'HIDDEN'}</Tag>{' '}
307
- </div>
308
- );
309
- }
310
-
311
- return (
312
- <Collapse
313
- key={menu.id}
314
- style={{ marginBottom: 6 }}
315
- // defaultActiveKey={[menu.id]}
316
- >
317
- <Panel
318
- key={menu.id}
319
- header={
320
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
321
- <div
322
- style={{
323
- display: 'flex',
324
- alignItems: 'center',
325
- gap: 8,
326
- }}
327
- onClick={(e) => e.stopPropagation()}
328
- >
329
- <Checkbox checked={parentChecked} indeterminate={parentIndeterminate} onChange={(e) => onParentChange(e.target.checked)} />
330
- <span>{menu.caption}</span>
331
- </div>
332
- <Tag color={menu.is_visible === true ? 'green' : 'blue'}>{menu.is_visible === true ? 'VISIBLE' : 'HIDDEN'}</Tag>{' '}
333
- </div>
334
- }
335
- >
336
- <div style={{ paddingLeft: 20 }}>
337
- <MenuTree menus={children} selectedMenus={selectedMenus} toggleMenu={toggleMenu} parentId={menu.id} />
338
- </div>
339
- </Panel>
340
- </Collapse>
341
- );
342
- })}
343
- </>
344
- );
345
- };
@@ -40,6 +40,12 @@ const RoleList = ({ model, match, relativeAdd = false, additional_queries = [],
40
40
 
41
41
  const [single, setSingle] = useState({});
42
42
 
43
+ // tracks RoleAdd form submission so the Drawer footer button shows loading
44
+ const [submitting, setSubmitting] = useState(false);
45
+
46
+ // holds the RoleAdd form instance so the Drawer footer Save button can submit it
47
+ const formRef = useRef(null);
48
+
43
49
  var [queryValue, setQueryValue] = useState('');
44
50
 
45
51
  const { Search } = Input;
@@ -360,12 +366,26 @@ const RoleList = ({ model, match, relativeAdd = false, additional_queries = [],
360
366
  destroyOnClose
361
367
  onClose={closeModal}
362
368
  bodyStyle={{ paddingBottom: 80 }}
369
+ footer={
370
+ <div style={{ textAlign: 'right' }}>
371
+ <Space size="small">
372
+ <Button type="default" onClick={closeModal}>
373
+ Cancel
374
+ </Button>
375
+ <Button type="primary" loading={submitting} onClick={() => formRef.current?.submit()}>
376
+ Save
377
+ </Button>
378
+ </Space>
379
+ </div>
380
+ }
363
381
  >
364
382
  <model.ModalAddComponent
365
383
  match={match}
366
384
  model={model}
367
385
  additional_queries={additional_queries}
368
386
  formContent={single}
387
+ formRef={formRef}
388
+ onSubmittingChange={setSubmitting}
369
389
  callback={(event) => {
370
390
  closeModal();
371
391
  getRecords();
@@ -160,7 +160,7 @@ export default function AssignRole() {
160
160
  const role_id = role.id;
161
161
  try {
162
162
  const res = await MenusAPI.getCoreMenuByRoleId(role_id);
163
- const allMenus = res.result || [];
163
+ const allMenus = Array.isArray(res?.result) ? res.result : [];
164
164
 
165
165
  setModules(allMenus);
166
166
  } catch (e) {
@@ -264,11 +264,12 @@ export default function AssignRole() {
264
264
  const handleViewAll = async () => {
265
265
  setLoadingMenus(true);
266
266
  setModules([]);
267
- setActiveRole({ name: 'All Roles' });
267
+ setActiveRole({ name: 'All Roles', isViewAll: true });
268
268
 
269
269
  try {
270
270
  const res = await MenusAPI.getMenubyUser(id);
271
- setModules(res?.result?.menus ?? []);
271
+ const userMenus = Array.isArray(res?.result?.menus) ? res.result.menus : [];
272
+ setModules(userMenus);
272
273
  setLoadingMenus(false);
273
274
  } catch (err) {
274
275
  setLoadingMenus(false);
@@ -277,6 +278,18 @@ export default function AssignRole() {
277
278
  }
278
279
  };
279
280
 
281
+ /**
282
+ * Context-specific empty-state message for the right (menus) panel:
283
+ * - View Access for a user with no roles → no role assigned
284
+ * - A selected role with no menus → no menus assigned
285
+ * - Nothing selected yet → prompt to pick a role
286
+ */
287
+ const emptyMenusMessage = !activeRole
288
+ ? 'Select a role to view menus'
289
+ : activeRole.isViewAll
290
+ ? 'No role has been assigned to this user.'
291
+ : 'No menus have been assigned to this role.';
292
+
280
293
  return (
281
294
  <section className="assign-role">
282
295
  {/* LEFT PANEL */}
@@ -383,17 +396,19 @@ export default function AssignRole() {
383
396
  padding: 16,
384
397
  }}
385
398
  >
386
- <div className="menus-header">
387
- <div className="title">Menus {activeRole ? `– ${activeRole.name}` : ''}</div>
388
- <div className="sub-text">You don’t have permission to edit this here. Update it in Role Settings.</div>
389
- </div>
399
+ {modules.length > 0 && (
400
+ <div className="menus-header">
401
+ <div className="title">Menus {activeRole ? `– ${activeRole.name}` : ''}</div>
402
+ <div className="sub-text">You don’t have permission to edit this here. Update it in Role Settings.</div>
403
+ </div>
404
+ )}
390
405
 
391
406
  <div className="menus-content">
392
407
  {loadingMenus ? (
393
408
  <Skeleton active paragraph={{ rows: 6 }} />
394
409
  ) : modules.length === 0 ? (
395
410
  <div className="empty-state">
396
- <Empty description="Select a role to view menus" />
411
+ <Empty description={emptyMenusMessage} />
397
412
  </div>
398
413
  ) : (
399
414
  <MenuTree menus={modules} selectedMenus={selectedMenus} toggleMenu={toggleMenu} showCheckbox={false} />
@@ -1,7 +1,10 @@
1
- import React from 'react';
2
- import { Tag } from 'antd';
1
+ import React, { useState, useContext } from 'react';
2
+ import { Modal, Tag } from 'antd';
3
3
  import * as Icons from '@ant-design/icons';
4
4
  import { Link } from 'react-router-dom';
5
+ import { GlobalContext, safeJSON, Location } from '../../../../../lib';
6
+ // import { PdfViewer } from '../../../../../lib';
7
+ import { CoreScripts } from '../../../../../models';
5
8
 
6
9
  /**
7
10
  * Utilities for rendering Reporting Dashboard display columns.
@@ -74,7 +77,7 @@ export function isActionTypeEntry(entry = {}) {
74
77
  * @param {DisplayRecord} record
75
78
  * @returns {string}
76
79
  */
77
- export function getRedirectLink(entry = {}, record = {}) {
80
+ export function getRedirectLink(entry = {}, record = {}, CustomComponents = {}) {
78
81
  let redirectLink = entry.redirect_link || '';
79
82
 
80
83
  if (Array.isArray(entry.replace_variables)) {
@@ -87,6 +90,41 @@ export function getRedirectLink(entry = {}, record = {}) {
87
90
  return redirectLink;
88
91
  }
89
92
 
93
+ /**
94
+ * Resolves the PDF/file location for file-based action entries.
95
+ *
96
+ * Supports either:
97
+ * - `entry.file_location` as a direct URL/path
98
+ * - `entry.file_location` as a record field name
99
+ *
100
+ * @param {DisplayColumnEntry} entry
101
+ * @param {DisplayRecord} record
102
+ * @returns {string}
103
+ */
104
+ export function getFileLocation(entry = {}, record = {}) {
105
+ if (entry.file_location && typeof entry.file_location === 'string' && record[entry.file_location]) {
106
+ return record[entry.file_location];
107
+ }
108
+
109
+ return record.file_location || entry.file_location || '';
110
+ }
111
+
112
+ /**
113
+ * Resolves a value from the row when a config field points to a record key,
114
+ * otherwise returns the provided literal value.
115
+ *
116
+ * @param {*} value
117
+ * @param {DisplayRecord} record
118
+ * @returns {*}
119
+ */
120
+ function resolveRecordValue(value, record = {}) {
121
+ if (typeof value === 'string' && Object.prototype.hasOwnProperty.call(record, value)) {
122
+ return record[value];
123
+ }
124
+
125
+ return value;
126
+ }
127
+
90
128
  /**
91
129
  * Resolves action label text with backward-compatible precedence.
92
130
  *
@@ -170,6 +208,154 @@ function renderCustomComponent({ entry, record, CustomComponents, refresh }) {
170
208
  );
171
209
  }
172
210
 
211
+ /**
212
+ * Renders a PDF file action inside a modal viewer.
213
+ *
214
+ * @param {Object} root0
215
+ * @param {DisplayColumnEntry} root0.entry
216
+ * @param {DisplayRecord} root0.record
217
+ * @param {Object.<string, React.ComponentType<any>>} root0.CustomComponents
218
+ * @returns {React.ReactNode}
219
+ */
220
+ function FileActionLink({ entry, record, CustomComponents }) {
221
+ const [isPdfVisible, setIsPdfVisible] = useState(false);
222
+ const fileUrl = getFileLocation(entry, record);
223
+ const actionLabel = getActionLabel(entry, record);
224
+ const FileLoaderComponent = CustomComponents?.FileLoader;
225
+
226
+ if (!fileUrl) {
227
+ return null;
228
+ }
229
+
230
+ const fileLoaderProps = {
231
+ url: fileUrl,
232
+ type: resolveRecordValue(entry.file_type, record) || 'pdf',
233
+ defaultScale: resolveRecordValue(entry.default_scale, record),
234
+ viewerType: resolveRecordValue(entry.viewer_type, record),
235
+ config: {
236
+ requireLinuxPath: resolveRecordValue(entry.require_linux_path, record),
237
+ replaceBranch: resolveRecordValue(entry.replace_branch, record),
238
+ ...(entry.file_loader_config || {}),
239
+ },
240
+ entry,
241
+ record,
242
+ };
243
+
244
+ return (
245
+ <>
246
+ {/* 1. Add the trigger link/button here */}
247
+ <a
248
+ onClick={(e) => {
249
+ e.preventDefault();
250
+ setIsPdfVisible(true);
251
+ }}
252
+ style={{ cursor: 'pointer' }}
253
+ >
254
+ {actionLabel}
255
+ </a>
256
+
257
+ <Modal
258
+ open={isPdfVisible}
259
+ onCancel={() => setIsPdfVisible(false)}
260
+ footer={null}
261
+ destroyOnClose
262
+ width={950}
263
+ style={{ top: 10 }}
264
+ title={actionLabel}
265
+ >
266
+ {/* 2. Ensure the loader component exists */}
267
+ {FileLoaderComponent ? <FileLoaderComponent {...fileLoaderProps} /> : <p>Loader not found</p>}
268
+ </Modal>
269
+ </>
270
+ );
271
+ }
272
+
273
+ /**
274
+ * Returns the branch access list from the signed-in user's organization details.
275
+ *
276
+ * `organization_details` may arrive as a JSON string, so this helper keeps the
277
+ * parsing logic in one place before branch comparisons are made.
278
+ *
279
+ * @param {Object} user
280
+ * @returns {Array}
281
+ */
282
+ function getAccessibleBranches(user = {}) {
283
+ const orgDetails = safeJSON(user?.organization_details);
284
+ return Array.isArray(orgDetails?.branch) ? orgDetails.branch : [];
285
+ }
286
+
287
+ /**
288
+ * Resolves branch metadata used to decide whether an action link should prompt
289
+ * for a branch switch before navigation.
290
+ *
291
+ * When an action entry includes `replace_variables` with the `index` field, the
292
+ * row is treated as branch-aware content. We compare the record's branch id
293
+ * against the active branch from `localStorage.db_ptr`, and also resolve whether
294
+ * the user has access to the target branch.
295
+ *
296
+ * @param {DisplayColumnEntry} entry
297
+ * @param {DisplayRecord} record
298
+ * @param {Object} user
299
+ * @returns {{requiresBranchSwitch: boolean, hasTargetBranchAccess: boolean}}
300
+ */
301
+ function getBranchNavigationState(entry = {}, record = {}, user = {}) {
302
+ const branchFieldConfig = entry.replace_variables?.find((variable) => variable.field === 'index');
303
+
304
+ if (!branchFieldConfig) {
305
+ return { requiresBranchSwitch: false, hasTargetBranchAccess: false };
306
+ }
307
+
308
+ const accessibleBranches = getAccessibleBranches(user);
309
+ const activeDbPtr = localStorage.getItem('db_ptr');
310
+ const activeBranch = accessibleBranches.find((branch) => String(branch.dbPtr) === String(activeDbPtr));
311
+ const targetBranchId = record[branchFieldConfig.field];
312
+ const targetBranch = accessibleBranches.find((branch) => String(branch.branch_id) === String(targetBranchId));
313
+
314
+ return {
315
+ requiresBranchSwitch: Boolean(targetBranchId) && String(activeBranch?.branch_id) !== String(targetBranchId),
316
+ hasTargetBranchAccess: Boolean(targetBranch),
317
+ };
318
+ }
319
+
320
+ /**
321
+ * Renders a branch-switch confirmation link for cross-branch records.
322
+ *
323
+ * The actual branch change is handled by the landing page via the `index`
324
+ * query parameter. This link only confirms intent and blocks navigation when
325
+ * the user does not have access to the target branch.
326
+ *
327
+ * @param {Object} root0
328
+ * @param {string} root0.label
329
+ * @param {string} root0.redirectLink
330
+ * @param {boolean} root0.hasTargetBranchAccess
331
+ * @returns {React.ReactNode}
332
+ */
333
+ function BranchAwareActionLink({ label, redirectLink, hasTargetBranchAccess }) {
334
+ return (
335
+ <a
336
+ onClick={(e) => {
337
+ e.preventDefault();
338
+ Modal.confirm({
339
+ title: 'Switch Branch?', // Adding a title makes it look more standard
340
+ content: 'This record belongs to another branch. Would you like to switch branches to view the details?',
341
+ onOk: () => {
342
+ if (hasTargetBranchAccess) {
343
+ Location.navigate({ url: redirectLink });
344
+ return;
345
+ }
346
+
347
+ Modal.error({
348
+ title: 'Access Denied',
349
+ content: 'This data belongs to another branch.Would you like to switch branches to view the details?',
350
+ });
351
+ },
352
+ });
353
+ }}
354
+ >
355
+ {label}
356
+ </a>
357
+ );
358
+ }
173
359
  /**
174
360
  * Renders table cell content for a configured display column.
175
361
  *
@@ -210,10 +396,21 @@ export function renderDisplayCell({ entry, record, CustomComponents, refresh })
210
396
  }
211
397
  return null;
212
398
  }
213
-
214
399
  if (isLegacyActionEntry(entry) || isActionTypeEntry(entry)) {
400
+ if (entry.redirect_link_type === 'file') {
401
+ return <FileActionLink entry={entry} record={record} CustomComponents={CustomComponents} />;
402
+ }
403
+ const { user = {} } = useContext(GlobalContext);
404
+
215
405
  const redirectLink = getRedirectLink(entry, record);
216
- return <Link to={`${redirectLink}`}>{getActionLabel(entry, record)}</Link>;
406
+ const label = getActionLabel(entry, record);
407
+ const { requiresBranchSwitch, hasTargetBranchAccess } = getBranchNavigationState(entry, record, user);
408
+
409
+ if (requiresBranchSwitch) {
410
+ return <BranchAwareActionLink label={label} redirectLink={redirectLink} hasTargetBranchAccess={hasTargetBranchAccess} />;
411
+ }
412
+
413
+ return <Link to={`${redirectLink}`}>{label}</Link>;
217
414
  }
218
415
 
219
416
  if (entry.field === 'custom') {