feat: update organization pages and test modal
- Update organization discipline and site pages with new features - Enhance TestFormModal component - Refactor GroupMembersTab implementation - Update API documentation - Add organization API methods
This commit is contained in:
parent
695ee3de91
commit
22ee1ebfd1
@ -125,3 +125,8 @@ language_backend:
|
|||||||
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
||||||
# Extends the list from the global configuration, merging the two lists.
|
# Extends the list from the global configuration, merging the two lists.
|
||||||
read_only_memory_patterns: []
|
read_only_memory_patterns: []
|
||||||
|
|
||||||
|
# line ending convention to use when writing source files.
|
||||||
|
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
|
||||||
|
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
|
||||||
|
line_ending:
|
||||||
|
|||||||
@ -51,6 +51,8 @@ tags:
|
|||||||
description: Demo/test endpoints (no authentication)
|
description: Demo/test endpoints (no authentication)
|
||||||
- name: EquipmentList
|
- name: EquipmentList
|
||||||
description: Laboratory equipment and instrument management
|
description: Laboratory equipment and instrument management
|
||||||
|
- name: Users
|
||||||
|
description: User management and administration
|
||||||
paths:
|
paths:
|
||||||
/api/auth/login:
|
/api/auth/login:
|
||||||
post:
|
post:
|
||||||
@ -3104,6 +3106,68 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Specimen details
|
description: Specimen details
|
||||||
|
delete:
|
||||||
|
tags:
|
||||||
|
- Specimen
|
||||||
|
summary: Delete specimen (soft delete)
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
description: Specimen ID (SID)
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Specimen deleted successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
example: success
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: Specimen deleted successfully
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
SID:
|
||||||
|
type: integer
|
||||||
|
'404':
|
||||||
|
description: Specimen not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
example: failed
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: Specimen not found
|
||||||
|
data:
|
||||||
|
type: null
|
||||||
|
'500':
|
||||||
|
description: Server error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
example: failed
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: Failed to delete specimen
|
||||||
|
data:
|
||||||
|
type: null
|
||||||
/api/specimen/container:
|
/api/specimen/container:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@ -3845,7 +3909,7 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Batch delete results
|
description: Batch delete results
|
||||||
/api/tests:
|
/api/test:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
- Tests
|
- Tests
|
||||||
@ -4170,7 +4234,7 @@ paths:
|
|||||||
properties:
|
properties:
|
||||||
TestSiteId:
|
TestSiteId:
|
||||||
type: integer
|
type: integer
|
||||||
/api/tests/{id}:
|
/api/test/{id}:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
- Tests
|
- Tests
|
||||||
@ -4247,6 +4311,219 @@ paths:
|
|||||||
description: Test not found
|
description: Test not found
|
||||||
'422':
|
'422':
|
||||||
description: Test already disabled
|
description: Test already disabled
|
||||||
|
/api/users:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Users
|
||||||
|
summary: List users with pagination and search
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: page
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 1
|
||||||
|
description: Page number
|
||||||
|
- name: per_page
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 20
|
||||||
|
description: Items per page
|
||||||
|
- name: search
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Search term for username, email, or name
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: List of users with pagination
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
example: success
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: Users retrieved successfully
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
users:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/User'
|
||||||
|
pagination:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
current_page:
|
||||||
|
type: integer
|
||||||
|
per_page:
|
||||||
|
type: integer
|
||||||
|
total:
|
||||||
|
type: integer
|
||||||
|
total_pages:
|
||||||
|
type: integer
|
||||||
|
'500':
|
||||||
|
description: Server error
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Users
|
||||||
|
summary: Create new user
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/UserCreate'
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: User created successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
example: success
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: User created successfully
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
UserID:
|
||||||
|
type: integer
|
||||||
|
Username:
|
||||||
|
type: string
|
||||||
|
Email:
|
||||||
|
type: string
|
||||||
|
'400':
|
||||||
|
description: Validation failed
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
example: failed
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: Validation failed
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
'500':
|
||||||
|
description: Server error
|
||||||
|
patch:
|
||||||
|
tags:
|
||||||
|
- Users
|
||||||
|
summary: Update existing user
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/UserUpdate'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: User updated successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
example: success
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: User updated successfully
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
UserID:
|
||||||
|
type: integer
|
||||||
|
updated_fields:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
'400':
|
||||||
|
description: UserID is required
|
||||||
|
'404':
|
||||||
|
description: User not found
|
||||||
|
'500':
|
||||||
|
description: Server error
|
||||||
|
/api/users/{id}:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Users
|
||||||
|
summary: Get user by ID
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
description: User ID
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: User details
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/User'
|
||||||
|
'404':
|
||||||
|
description: User not found
|
||||||
|
'500':
|
||||||
|
description: Server error
|
||||||
|
delete:
|
||||||
|
tags:
|
||||||
|
- Users
|
||||||
|
summary: Delete user (soft delete)
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
description: User ID
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: User deleted successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
example: success
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: User deleted successfully
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
UserID:
|
||||||
|
type: integer
|
||||||
|
'404':
|
||||||
|
description: User not found
|
||||||
|
'500':
|
||||||
|
description: Server error
|
||||||
/api/valueset:
|
/api/valueset:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@ -5242,6 +5519,8 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
SiteCode:
|
SiteCode:
|
||||||
type: string
|
type: string
|
||||||
|
maxLength: 2
|
||||||
|
pattern: ^[A-Z0-9]{2}$
|
||||||
AccountID:
|
AccountID:
|
||||||
type: integer
|
type: integer
|
||||||
Discipline:
|
Discipline:
|
||||||
@ -5627,6 +5906,34 @@ components:
|
|||||||
description: Group members (only for GROUP type)
|
description: Group members (only for GROUP type)
|
||||||
items:
|
items:
|
||||||
type: object
|
type: object
|
||||||
|
properties:
|
||||||
|
TestGrpID:
|
||||||
|
type: integer
|
||||||
|
description: Group membership record ID
|
||||||
|
TestSiteID:
|
||||||
|
type: integer
|
||||||
|
description: Parent group TestSiteID
|
||||||
|
Member:
|
||||||
|
type: integer
|
||||||
|
description: Member TestSiteID (foreign key to testdefsite)
|
||||||
|
MemberTestSiteID:
|
||||||
|
type: integer
|
||||||
|
description: Member's actual TestSiteID (same as Member, for clarity)
|
||||||
|
TestSiteCode:
|
||||||
|
type: string
|
||||||
|
description: Member test code
|
||||||
|
TestSiteName:
|
||||||
|
type: string
|
||||||
|
description: Member test name
|
||||||
|
TestType:
|
||||||
|
type: string
|
||||||
|
description: Member test type
|
||||||
|
CreateDate:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
EndDate:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
testmap:
|
testmap:
|
||||||
type: array
|
type: array
|
||||||
description: Test mappings
|
description: Test mappings
|
||||||
@ -5886,13 +6193,19 @@ components:
|
|||||||
CountStat: 1
|
CountStat: 1
|
||||||
testdefgrp:
|
testdefgrp:
|
||||||
- TestGrpID: 1
|
- TestGrpID: 1
|
||||||
|
TestSiteID: 6
|
||||||
Member: 100
|
Member: 100
|
||||||
|
MemberTestSiteID: 100
|
||||||
TestSiteCode: CHOL
|
TestSiteCode: CHOL
|
||||||
TestSiteName: Total Cholesterol
|
TestSiteName: Total Cholesterol
|
||||||
|
TestType: TEST
|
||||||
- TestGrpID: 2
|
- TestGrpID: 2
|
||||||
|
TestSiteID: 6
|
||||||
Member: 101
|
Member: 101
|
||||||
|
MemberTestSiteID: 101
|
||||||
TestSiteCode: TG
|
TestSiteCode: TG
|
||||||
TestSiteName: Triglycerides
|
TestSiteName: Triglycerides
|
||||||
|
TestType: TEST
|
||||||
TITLE:
|
TITLE:
|
||||||
summary: Section header
|
summary: Section header
|
||||||
value:
|
value:
|
||||||
@ -6268,6 +6581,124 @@ components:
|
|||||||
WorkstationName:
|
WorkstationName:
|
||||||
type: string
|
type: string
|
||||||
description: Joined workstation name
|
description: Joined workstation name
|
||||||
|
User:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
UserID:
|
||||||
|
type: integer
|
||||||
|
description: Unique user identifier
|
||||||
|
Username:
|
||||||
|
type: string
|
||||||
|
description: Unique login username
|
||||||
|
Email:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
description: User email address
|
||||||
|
Name:
|
||||||
|
type: string
|
||||||
|
description: Full name of the user
|
||||||
|
Role:
|
||||||
|
type: string
|
||||||
|
description: User role (admin, technician, doctor, etc.)
|
||||||
|
Department:
|
||||||
|
type: string
|
||||||
|
description: Department name
|
||||||
|
IsActive:
|
||||||
|
type: boolean
|
||||||
|
description: Whether the user account is active
|
||||||
|
CreatedAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: Creation timestamp
|
||||||
|
UpdatedAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: Last update timestamp
|
||||||
|
DelDate:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
description: Soft delete timestamp (null if active)
|
||||||
|
UserCreate:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- Username
|
||||||
|
- Email
|
||||||
|
properties:
|
||||||
|
Username:
|
||||||
|
type: string
|
||||||
|
minLength: 3
|
||||||
|
maxLength: 50
|
||||||
|
description: Unique login username
|
||||||
|
Email:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
maxLength: 100
|
||||||
|
description: User email address
|
||||||
|
Name:
|
||||||
|
type: string
|
||||||
|
description: Full name of the user
|
||||||
|
Role:
|
||||||
|
type: string
|
||||||
|
description: User role
|
||||||
|
Department:
|
||||||
|
type: string
|
||||||
|
description: Department name
|
||||||
|
IsActive:
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
description: Whether the user account is active
|
||||||
|
UserUpdate:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- UserID
|
||||||
|
properties:
|
||||||
|
UserID:
|
||||||
|
type: integer
|
||||||
|
description: User ID to update
|
||||||
|
Email:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
description: User email address
|
||||||
|
Name:
|
||||||
|
type: string
|
||||||
|
description: Full name of the user
|
||||||
|
Role:
|
||||||
|
type: string
|
||||||
|
description: User role
|
||||||
|
Department:
|
||||||
|
type: string
|
||||||
|
description: Department name
|
||||||
|
IsActive:
|
||||||
|
type: boolean
|
||||||
|
description: Whether the user account is active
|
||||||
|
UserListResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
example: success
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: Users retrieved successfully
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
users:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/User'
|
||||||
|
pagination:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
current_page:
|
||||||
|
type: integer
|
||||||
|
per_page:
|
||||||
|
type: integer
|
||||||
|
total:
|
||||||
|
type: integer
|
||||||
|
total_pages:
|
||||||
|
type: integer
|
||||||
Contact:
|
Contact:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@ -15,6 +15,8 @@ export async function createDiscipline(data) {
|
|||||||
DisciplineCode: data.DisciplineCode,
|
DisciplineCode: data.DisciplineCode,
|
||||||
DisciplineName: data.DisciplineName,
|
DisciplineName: data.DisciplineName,
|
||||||
Parent: data.Parent || null,
|
Parent: data.Parent || null,
|
||||||
|
SeqScr: data.SeqScr,
|
||||||
|
SeqRpt: data.SeqRpt,
|
||||||
};
|
};
|
||||||
return post('/api/organization/discipline', payload);
|
return post('/api/organization/discipline', payload);
|
||||||
}
|
}
|
||||||
@ -25,6 +27,8 @@ export async function updateDiscipline(data) {
|
|||||||
DisciplineCode: data.DisciplineCode,
|
DisciplineCode: data.DisciplineCode,
|
||||||
DisciplineName: data.DisciplineName,
|
DisciplineName: data.DisciplineName,
|
||||||
Parent: data.Parent || null,
|
Parent: data.Parent || null,
|
||||||
|
SeqScr: data.SeqScr,
|
||||||
|
SeqRpt: data.SeqRpt,
|
||||||
};
|
};
|
||||||
return patch('/api/organization/discipline', payload);
|
return patch('/api/organization/discipline', payload);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,8 @@
|
|||||||
DisciplineCode: '',
|
DisciplineCode: '',
|
||||||
DisciplineName: '',
|
DisciplineName: '',
|
||||||
Parent: null,
|
Parent: null,
|
||||||
|
SeqScr: 0,
|
||||||
|
SeqRpt: 0,
|
||||||
});
|
});
|
||||||
let deleteConfirmOpen = $state(false);
|
let deleteConfirmOpen = $state(false);
|
||||||
let deleteItem = $state(null);
|
let deleteItem = $state(null);
|
||||||
@ -30,6 +32,8 @@
|
|||||||
const columns = [
|
const columns = [
|
||||||
{ key: 'DisciplineCode', label: 'Code', class: 'font-medium' },
|
{ key: 'DisciplineCode', label: 'Code', class: 'font-medium' },
|
||||||
{ key: 'DisciplineName', label: 'Name' },
|
{ key: 'DisciplineName', label: 'Name' },
|
||||||
|
{ key: 'SeqScr', label: 'Screen Seq', class: 'w-24 text-center' },
|
||||||
|
{ key: 'SeqRpt', label: 'Report Seq', class: 'w-24 text-center' },
|
||||||
{ key: 'ParentName', label: 'Parent Discipline', class: 'w-48' },
|
{ key: 'ParentName', label: 'Parent Discipline', class: 'w-48' },
|
||||||
{ key: 'actions', label: 'Actions', class: 'w-32 text-center' },
|
{ key: 'actions', label: 'Actions', class: 'w-32 text-center' },
|
||||||
];
|
];
|
||||||
@ -73,7 +77,7 @@
|
|||||||
|
|
||||||
function openCreateModal() {
|
function openCreateModal() {
|
||||||
modalMode = 'create';
|
modalMode = 'create';
|
||||||
formData = { DisciplineID: null, DisciplineCode: '', DisciplineName: '', Parent: null };
|
formData = { DisciplineID: null, DisciplineCode: '', DisciplineName: '', Parent: null, SeqScr: 0, SeqRpt: 0 };
|
||||||
modalOpen = true;
|
modalOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,6 +88,8 @@
|
|||||||
DisciplineCode: row.DisciplineCode,
|
DisciplineCode: row.DisciplineCode,
|
||||||
DisciplineName: row.DisciplineName,
|
DisciplineName: row.DisciplineName,
|
||||||
Parent: row.Parent || null,
|
Parent: row.Parent || null,
|
||||||
|
SeqScr: row.SeqScr ?? 0,
|
||||||
|
SeqRpt: row.SeqRpt ?? 0,
|
||||||
};
|
};
|
||||||
modalOpen = true;
|
modalOpen = true;
|
||||||
}
|
}
|
||||||
@ -97,6 +103,14 @@
|
|||||||
toastError('Discipline name is required');
|
toastError('Discipline name is required');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (formData.SeqScr === null || formData.SeqScr === undefined || formData.SeqScr === '') {
|
||||||
|
toastError('Screen sequence is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (formData.SeqRpt === null || formData.SeqRpt === undefined || formData.SeqRpt === '') {
|
||||||
|
toastError('Report sequence is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
saving = true;
|
saving = true;
|
||||||
try {
|
try {
|
||||||
@ -271,6 +285,40 @@
|
|||||||
</select>
|
</select>
|
||||||
<span class="label-text-alt text-xs text-gray-500">Optional parent for hierarchical structure</span>
|
<span class="label-text-alt text-xs text-gray-500">Optional parent for hierarchical structure</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="SeqScr">
|
||||||
|
<span class="label-text text-sm font-medium">Screen Sequence</span>
|
||||||
|
<span class="label-text-alt text-xs text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="SeqScr"
|
||||||
|
type="number"
|
||||||
|
class="input input-sm input-bordered w-full"
|
||||||
|
bind:value={formData.SeqScr}
|
||||||
|
placeholder="0"
|
||||||
|
required
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
<span class="label-text-alt text-xs text-gray-500">Order for screen display</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="SeqRpt">
|
||||||
|
<span class="label-text text-sm font-medium">Report Sequence</span>
|
||||||
|
<span class="label-text-alt text-xs text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="SeqRpt"
|
||||||
|
type="number"
|
||||||
|
class="input input-sm input-bordered w-full"
|
||||||
|
bind:value={formData.SeqRpt}
|
||||||
|
placeholder="0"
|
||||||
|
required
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
<span class="label-text-alt text-xs text-gray-500">Order for report display</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{#snippet footer()}
|
{#snippet footer()}
|
||||||
<button class="btn btn-ghost" onclick={() => (modalOpen = false)} type="button">Cancel</button>
|
<button class="btn btn-ghost" onclick={() => (modalOpen = false)} type="button">Cancel</button>
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
|
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
|
||||||
import DataTable from '$lib/components/DataTable.svelte';
|
import DataTable from '$lib/components/DataTable.svelte';
|
||||||
import Modal from '$lib/components/Modal.svelte';
|
import Modal from '$lib/components/Modal.svelte';
|
||||||
import { Plus, Edit2, Trash2, ArrowLeft, Search, LandPlot } from 'lucide-svelte';
|
import { Plus, Edit2, Trash2, ArrowLeft, Search, LandPlot, Check } from 'lucide-svelte';
|
||||||
|
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let items = $state([]);
|
let items = $state([]);
|
||||||
@ -29,6 +29,17 @@
|
|||||||
let deleting = $state(false);
|
let deleting = $state(false);
|
||||||
let searchQuery = $state('');
|
let searchQuery = $state('');
|
||||||
|
|
||||||
|
// Site code validation (must be exactly 2 characters)
|
||||||
|
let siteCodeLength = $derived(formData.SiteCode?.length || 0);
|
||||||
|
let isSiteCodeValid = $derived(siteCodeLength === 2);
|
||||||
|
let siteCodeBadgeClass = $derived(
|
||||||
|
siteCodeLength === 0
|
||||||
|
? 'badge-ghost'
|
||||||
|
: isSiteCodeValid
|
||||||
|
? 'badge-success'
|
||||||
|
: 'badge-error'
|
||||||
|
);
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ key: 'SiteCode', label: 'Code', class: 'font-medium' },
|
{ key: 'SiteCode', label: 'Code', class: 'font-medium' },
|
||||||
{ key: 'SiteName', label: 'Name' },
|
{ key: 'SiteName', label: 'Name' },
|
||||||
@ -103,6 +114,10 @@
|
|||||||
toastError('Site code is required');
|
toastError('Site code is required');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (formData.SiteCode.trim().length !== 2) {
|
||||||
|
toastError('Site code must be exactly 2 characters');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!formData.SiteName.trim()) {
|
if (!formData.SiteName.trim()) {
|
||||||
toastError('Site name is required');
|
toastError('Site name is required');
|
||||||
return;
|
return;
|
||||||
@ -239,15 +254,30 @@
|
|||||||
<span class="label-text text-sm font-medium">Site Code</span>
|
<span class="label-text text-sm font-medium">Site Code</span>
|
||||||
<span class="label-text-alt text-xs text-error">*</span>
|
<span class="label-text-alt text-xs text-error">*</span>
|
||||||
</label>
|
</label>
|
||||||
|
<div class="relative">
|
||||||
<input
|
<input
|
||||||
id="siteCode"
|
id="siteCode"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-sm input-bordered w-full"
|
class="input input-sm input-bordered w-full pr-16"
|
||||||
|
class:input-success={isSiteCodeValid}
|
||||||
|
class:input-error={siteCodeLength > 0 && !isSiteCodeValid}
|
||||||
bind:value={formData.SiteCode}
|
bind:value={formData.SiteCode}
|
||||||
placeholder="e.g., SITE001"
|
placeholder="e.g., AB"
|
||||||
|
maxlength="10"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<span class="label-text-alt text-xs text-gray-500">Unique identifier for this site</span>
|
<div class="absolute right-2 top-1/2 -translate-y-1/2">
|
||||||
|
<span class="badge badge-sm {siteCodeBadgeClass}">
|
||||||
|
{#if isSiteCodeValid}
|
||||||
|
<Check class="w-3 h-3 mr-1" />
|
||||||
|
{/if}
|
||||||
|
{siteCodeLength}/2
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="label-text-alt text-xs text-gray-500">
|
||||||
|
Must be exactly 2 characters (e.g., AB, NY, 01)
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label" for="siteName">
|
<label class="label" for="siteName">
|
||||||
@ -275,7 +305,7 @@
|
|||||||
bind:value={formData.AccountID}
|
bind:value={formData.AccountID}
|
||||||
>
|
>
|
||||||
<option value={null}>Select account...</option>
|
<option value={null}>Select account...</option>
|
||||||
{#each accounts as account}
|
{#each accounts as account (account.id || account.AccountID)}
|
||||||
<option value={account.id}>{account.AccountName}</option>
|
<option value={account.id}>{account.AccountName}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@ -188,8 +188,14 @@
|
|||||||
FormulaInput: test.FormulaInput || '',
|
FormulaInput: test.FormulaInput || '',
|
||||||
FormulaCode: test.FormulaCode || '',
|
FormulaCode: test.FormulaCode || '',
|
||||||
members: test.testdefgrp?.map(m => ({
|
members: test.testdefgrp?.map(m => ({
|
||||||
TestSiteID: parseInt(m.TestSiteID),
|
TestSiteID: m.TestSiteID,
|
||||||
Member: parseInt(m.Member)
|
TestSiteCode: m.TestSiteCode,
|
||||||
|
TestSiteName: m.TestSiteName,
|
||||||
|
TestType: m.TestType,
|
||||||
|
TestTypeLabel: m.TestTypeLabel,
|
||||||
|
SeqScr: m.SeqScr,
|
||||||
|
SeqRpt: m.SeqRpt,
|
||||||
|
Member: parseInt(m.Member || m.SeqScr || 0)
|
||||||
})) || []
|
})) || []
|
||||||
},
|
},
|
||||||
refnum: test.refnum || [],
|
refnum: test.refnum || [],
|
||||||
|
|||||||
@ -1,187 +1,201 @@
|
|||||||
<script>
|
<script>
|
||||||
import { Plus, Trash2, ArrowUp, ArrowDown, Box } from 'lucide-svelte';
|
import { Plus, Trash2, Box, Search } from 'lucide-svelte';
|
||||||
|
|
||||||
let { formData = $bindable(), tests = [], isDirty = $bindable(false) } = $props();
|
let { formData = $bindable(), tests = [], isDirty = $bindable(false) } = $props();
|
||||||
|
|
||||||
let availableTests = $state([]);
|
let searchQuery = $state('');
|
||||||
let selectedTestId = $state('');
|
|
||||||
let addMemberOpen = $state(false);
|
|
||||||
|
|
||||||
const members = $derived.by(() => {
|
const members = $derived.by(() => {
|
||||||
const memberIds = formData.details.members?.map(m => Number(m.TestSiteID)) || [];
|
const testdefgrp = formData.testdefgrp || formData.details?.members || [];
|
||||||
return tests.filter(t => memberIds.includes(Number(t.TestSiteID)))
|
return testdefgrp
|
||||||
.map(t => {
|
.map(m => ({
|
||||||
const memberObj = formData.details.members?.find(m => Number(m.TestSiteID) === Number(t.TestSiteID));
|
TestSiteID: m.TestSiteID,
|
||||||
return { ...t, seq: memberObj?.Member || 0 };
|
TestSiteCode: m.TestSiteCode,
|
||||||
})
|
TestSiteName: m.TestSiteName,
|
||||||
.sort((a, b) => a.seq - b.seq);
|
TestType: m.TestType,
|
||||||
|
TestTypeLabel: m.TestTypeLabel,
|
||||||
|
SeqScr: m.SeqScr || m.Member || 0
|
||||||
|
}))
|
||||||
|
.sort((a, b) => parseInt(a.SeqScr) - parseInt(b.SeqScr));
|
||||||
});
|
});
|
||||||
|
|
||||||
const availableOptions = $derived.by(() => {
|
const availableTests = $derived.by(() => {
|
||||||
const memberIds = formData.details.members?.map(m => Number(m.TestSiteID)) || [];
|
const currentMembers = formData.testdefgrp || formData.details?.members || [];
|
||||||
return tests.filter(t =>
|
const memberIds = new Set(currentMembers.map(m => Number(m.TestSiteID)));
|
||||||
|
|
||||||
|
let filtered = tests.filter(t =>
|
||||||
Number(t.TestSiteID) !== Number(formData.TestSiteID) &&
|
Number(t.TestSiteID) !== Number(formData.TestSiteID) &&
|
||||||
!memberIds.includes(Number(t.TestSiteID)) &&
|
!memberIds.has(Number(t.TestSiteID)) &&
|
||||||
t.IsActive !== '0' &&
|
t.IsActive !== '0' &&
|
||||||
t.IsActive !== 0
|
t.IsActive !== 0
|
||||||
).map(t => ({
|
);
|
||||||
value: t.TestSiteID,
|
|
||||||
label: `${t.TestSiteCode} - ${t.TestSiteName}`,
|
if (searchQuery.trim()) {
|
||||||
data: t
|
const query = searchQuery.toLowerCase();
|
||||||
}));
|
filtered = filtered.filter(t =>
|
||||||
|
t.TestSiteCode?.toLowerCase().includes(query) ||
|
||||||
|
t.TestSiteName?.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered.sort((a, b) => parseInt(a.SeqScr || 0) - parseInt(b.SeqScr || 0));
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleFieldChange() {
|
function handleFieldChange() {
|
||||||
isDirty = true;
|
isDirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addMember() {
|
function addMember(test) {
|
||||||
if (!selectedTestId) return;
|
const currentMembers = formData.testdefgrp || formData.details?.members || [];
|
||||||
|
|
||||||
const currentMembers = formData.details.members || [];
|
|
||||||
const newMember = {
|
const newMember = {
|
||||||
TestSiteID: parseInt(selectedTestId),
|
TestSiteID: test.TestSiteID,
|
||||||
Member: currentMembers.length + 1
|
TestSiteCode: test.TestSiteCode || '',
|
||||||
|
TestSiteName: test.TestSiteName || '',
|
||||||
|
TestType: test.TestType || 'TEST',
|
||||||
|
TestTypeLabel: test.TestTypeLabel || 'Test',
|
||||||
|
SeqScr: test.SeqScr || '0'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (formData.hasOwnProperty('testdefgrp')) {
|
||||||
|
formData.testdefgrp = [...currentMembers, newMember];
|
||||||
|
} else {
|
||||||
formData.details.members = [...currentMembers, newMember];
|
formData.details.members = [...currentMembers, newMember];
|
||||||
selectedTestId = '';
|
}
|
||||||
|
|
||||||
handleFieldChange();
|
handleFieldChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeMember(testId) {
|
function removeMember(testId) {
|
||||||
const remainingMembers = formData.details.members?.filter(m => Number(m.TestSiteID) !== Number(testId)) || [];
|
const isNewApi = formData.hasOwnProperty('testdefgrp');
|
||||||
// Re-sequence the remaining members
|
const currentMembers = formData.testdefgrp || formData.details?.members || [];
|
||||||
formData.details.members = remainingMembers.map((m, idx) => ({
|
const remainingMembers = currentMembers.filter(m => Number(m.TestSiteID) !== Number(testId));
|
||||||
...m,
|
|
||||||
Member: idx + 1
|
|
||||||
}));
|
|
||||||
handleFieldChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
function moveMember(index, direction) {
|
if (isNewApi) {
|
||||||
const members = [...formData.details.members];
|
formData.testdefgrp = remainingMembers;
|
||||||
const newIndex = index + direction;
|
} else {
|
||||||
|
formData.details.members = remainingMembers;
|
||||||
if (newIndex >= 0 && newIndex < members.length) {
|
|
||||||
// Swap the members
|
|
||||||
[members[index], members[newIndex]] = [members[newIndex], members[index]];
|
|
||||||
// Update sequence numbers
|
|
||||||
formData.details.members = members.map((m, idx) => ({
|
|
||||||
...m,
|
|
||||||
Member: idx + 1
|
|
||||||
}));
|
|
||||||
handleFieldChange();
|
|
||||||
}
|
}
|
||||||
|
handleFieldChange();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-6">
|
|
||||||
<h2 class="text-lg font-semibold text-gray-800">Group Members</h2>
|
|
||||||
|
|
||||||
<div class="alert alert-info text-sm">
|
|
||||||
<Box class="w-4 h-4" />
|
|
||||||
<div>
|
|
||||||
<strong>Panel Members:</strong> Add tests, parameters, or calculated values to this panel. Order matters for display on reports.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<h3 class="text-sm font-medium text-gray-600 uppercase tracking-wide">Current Members ({members.length})</h3>
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800">Group Members</h2>
|
||||||
|
<span class="badge badge-sm badge-ghost">{members.length} selected</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if members.length === 0}
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 h-[500px] overflow-hidden">
|
||||||
<div class="text-center py-8 bg-base-200 rounded-lg">
|
<!-- Left Column: Available Tests -->
|
||||||
<Box class="w-12 h-12 mx-auto text-gray-400 mb-2" />
|
<div class="flex flex-col border border-base-300 rounded-lg bg-base-100 overflow-hidden">
|
||||||
<p class="text-sm text-gray-500">No members added yet</p>
|
<div class="p-3 border-b border-base-300 bg-base-200 rounded-t-lg shrink-0">
|
||||||
<p class="text-xs text-gray-400">Add tests to create this panel</p>
|
<h3 class="text-sm font-medium text-gray-700 mb-2">Available Tests</h3>
|
||||||
|
<label class="input input-sm input-bordered flex items-center gap-2 w-full">
|
||||||
|
<Search class="w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="grow bg-transparent outline-none text-sm"
|
||||||
|
placeholder="Search by code or name..."
|
||||||
|
bind:value={searchQuery}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto p-2 space-y-1 min-h-0">
|
||||||
|
{#if availableTests.length === 0}
|
||||||
|
<div class="text-center py-8 text-gray-500">
|
||||||
|
<Box class="w-10 h-10 mx-auto mb-2 opacity-50" />
|
||||||
|
<p class="text-sm">No tests available</p>
|
||||||
|
<p class="text-xs opacity-70">
|
||||||
|
{searchQuery ? 'Try a different search term' : 'All tests are already added'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="overflow-x-auto border border-base-200 rounded-lg">
|
{#each availableTests as test (test.TestSiteID)}
|
||||||
<table class="table table-sm table-compact">
|
<div class="flex items-center justify-between p-2 hover:bg-base-200 rounded-md group">
|
||||||
<thead>
|
<div class="flex-1 min-w-0">
|
||||||
<tr class="bg-base-200">
|
<div class="flex items-center gap-2">
|
||||||
<th class="w-12 text-center">#</th>
|
<span class="font-mono text-xs text-gray-500 w-8">{test.SeqScr || '-'}</span>
|
||||||
<th class="w-24">Code</th>
|
<span class="font-mono text-sm font-medium truncate">{test.TestSiteCode}</span>
|
||||||
<th>Name</th>
|
<span class="badge badge-xs badge-ghost">{test.TestType}</span>
|
||||||
<th class="w-20">Type</th>
|
</div>
|
||||||
<th class="w-32 text-center">Actions</th>
|
<p class="text-xs text-gray-600 truncate pl-10">{test.TestSiteName}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onclick={() => addMember(test)}
|
||||||
|
title="Add to group"
|
||||||
|
>
|
||||||
|
<Plus class="w-4 h-4 text-primary" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-2 border-t border-base-300 text-xs text-gray-500 text-center shrink-0">
|
||||||
|
{availableTests.length} tests available
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Column: Selected Members -->
|
||||||
|
<div class="flex flex-col border border-base-300 rounded-lg bg-base-100 overflow-hidden">
|
||||||
|
<div class="p-3 border-b border-base-300 bg-base-200 rounded-t-lg shrink-0">
|
||||||
|
<h3 class="text-sm font-medium text-gray-700">Selected Members</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto min-h-0">
|
||||||
|
{#if members.length === 0}
|
||||||
|
<div class="flex flex-col items-center justify-center h-full text-gray-500 py-8">
|
||||||
|
<Box class="w-12 h-12 mb-3 opacity-50" />
|
||||||
|
<p class="text-sm font-medium">No members selected</p>
|
||||||
|
<p class="text-xs opacity-70 mt-1">Click the + button on available tests to add them</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<table class="table table-sm w-full">
|
||||||
|
<thead class="sticky top-0 bg-base-200">
|
||||||
|
<tr>
|
||||||
|
<th class="w-12 text-center text-xs">Seq</th>
|
||||||
|
<th class="w-20 text-xs">Code</th>
|
||||||
|
<th class="text-xs">Name</th>
|
||||||
|
<th class="w-16 text-xs">Type</th>
|
||||||
|
<th class="w-10 text-center text-xs"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each members as member, idx (member.TestSiteID)}
|
{#each members as member (member.TestSiteID)}
|
||||||
<tr class="hover:bg-base-100">
|
<tr class="hover:bg-base-200">
|
||||||
<td class="text-center text-gray-500">{idx + 1}</td>
|
<td class="text-center font-mono text-xs text-gray-600">{member.SeqScr}</td>
|
||||||
<td class="font-mono text-sm">{member.TestSiteCode}</td>
|
<td class="font-mono text-xs">{member.TestSiteCode}</td>
|
||||||
<td>{member.TestSiteName}</td>
|
<td class="text-xs truncate max-w-[150px]" title={member.TestSiteName}>
|
||||||
|
{member.TestSiteName}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge badge-xs badge-ghost">{member.TestType}</span>
|
<span class="badge badge-xs badge-ghost">{member.TestType}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="text-center">
|
||||||
<div class="flex justify-center gap-1">
|
|
||||||
<button
|
<button
|
||||||
class="btn btn-ghost btn-xs"
|
class="btn btn-ghost btn-xs text-error p-0 min-h-0 h-auto"
|
||||||
onclick={() => moveMember(idx, -1)}
|
|
||||||
disabled={idx === 0}
|
|
||||||
title="Move Up"
|
|
||||||
>
|
|
||||||
<ArrowUp class="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn btn-ghost btn-xs"
|
|
||||||
onclick={() => moveMember(idx, 1)}
|
|
||||||
disabled={idx === members.length - 1}
|
|
||||||
title="Move Down"
|
|
||||||
>
|
|
||||||
<ArrowDown class="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn btn-ghost btn-xs text-error"
|
|
||||||
onclick={() => removeMember(member.TestSiteID)}
|
onclick={() => removeMember(member.TestSiteID)}
|
||||||
title="Remove Member"
|
title="Remove"
|
||||||
>
|
>
|
||||||
<Trash2 class="w-3 h-3" />
|
<Trash2 class="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="p-2 border-t border-base-300 text-xs text-gray-500 shrink-0">
|
||||||
<h3 class="text-sm font-medium text-gray-600 uppercase tracking-wide">Add Member</h3>
|
<p>Ordered by SeqScr value</p>
|
||||||
|
|
||||||
{#if availableOptions.length === 0}
|
|
||||||
<div class="alert alert-warning text-sm">
|
|
||||||
<span>No available tests to add. All tests are either already members or inactive.</span>
|
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<select
|
|
||||||
class="select select-sm select-bordered flex-1"
|
|
||||||
bind:value={selectedTestId}
|
|
||||||
>
|
|
||||||
<option value="">Select a test to add...</option>
|
|
||||||
{#each availableOptions as opt (opt.value)}
|
|
||||||
<option value={opt.value}>{opt.label}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
class="btn btn-sm btn-primary"
|
|
||||||
onclick={addMember}
|
|
||||||
disabled={!selectedTestId}
|
|
||||||
>
|
|
||||||
<Plus class="w-4 h-4 mr-1" />
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-xs text-gray-500 space-y-1">
|
<div class="text-xs text-gray-500">
|
||||||
<p><strong>Tip:</strong> Members will display on reports in the order shown above.</p>
|
<p><strong>Tip:</strong> Members are automatically ordered by their sequence number. Click + to add, trash icon to remove.</p>
|
||||||
<p><strong>Note:</strong> Panels cannot contain themselves or other panels (circular references).</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user