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.
|
||||
# Extends the list from the global configuration, merging the two lists.
|
||||
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)
|
||||
- name: EquipmentList
|
||||
description: Laboratory equipment and instrument management
|
||||
- name: Users
|
||||
description: User management and administration
|
||||
paths:
|
||||
/api/auth/login:
|
||||
post:
|
||||
@ -3104,6 +3106,68 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
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:
|
||||
get:
|
||||
tags:
|
||||
@ -3845,7 +3909,7 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: Batch delete results
|
||||
/api/tests:
|
||||
/api/test:
|
||||
get:
|
||||
tags:
|
||||
- Tests
|
||||
@ -4170,7 +4234,7 @@ paths:
|
||||
properties:
|
||||
TestSiteId:
|
||||
type: integer
|
||||
/api/tests/{id}:
|
||||
/api/test/{id}:
|
||||
get:
|
||||
tags:
|
||||
- Tests
|
||||
@ -4247,6 +4311,219 @@ paths:
|
||||
description: Test not found
|
||||
'422':
|
||||
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:
|
||||
get:
|
||||
tags:
|
||||
@ -5242,6 +5519,8 @@ components:
|
||||
type: string
|
||||
SiteCode:
|
||||
type: string
|
||||
maxLength: 2
|
||||
pattern: ^[A-Z0-9]{2}$
|
||||
AccountID:
|
||||
type: integer
|
||||
Discipline:
|
||||
@ -5627,6 +5906,34 @@ components:
|
||||
description: Group members (only for GROUP type)
|
||||
items:
|
||||
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:
|
||||
type: array
|
||||
description: Test mappings
|
||||
@ -5886,13 +6193,19 @@ components:
|
||||
CountStat: 1
|
||||
testdefgrp:
|
||||
- TestGrpID: 1
|
||||
TestSiteID: 6
|
||||
Member: 100
|
||||
MemberTestSiteID: 100
|
||||
TestSiteCode: CHOL
|
||||
TestSiteName: Total Cholesterol
|
||||
TestType: TEST
|
||||
- TestGrpID: 2
|
||||
TestSiteID: 6
|
||||
Member: 101
|
||||
MemberTestSiteID: 101
|
||||
TestSiteCode: TG
|
||||
TestSiteName: Triglycerides
|
||||
TestType: TEST
|
||||
TITLE:
|
||||
summary: Section header
|
||||
value:
|
||||
@ -6268,6 +6581,124 @@ components:
|
||||
WorkstationName:
|
||||
type: string
|
||||
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:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@ -15,6 +15,8 @@ export async function createDiscipline(data) {
|
||||
DisciplineCode: data.DisciplineCode,
|
||||
DisciplineName: data.DisciplineName,
|
||||
Parent: data.Parent || null,
|
||||
SeqScr: data.SeqScr,
|
||||
SeqRpt: data.SeqRpt,
|
||||
};
|
||||
return post('/api/organization/discipline', payload);
|
||||
}
|
||||
@ -25,6 +27,8 @@ export async function updateDiscipline(data) {
|
||||
DisciplineCode: data.DisciplineCode,
|
||||
DisciplineName: data.DisciplineName,
|
||||
Parent: data.Parent || null,
|
||||
SeqScr: data.SeqScr,
|
||||
SeqRpt: data.SeqRpt,
|
||||
};
|
||||
return patch('/api/organization/discipline', payload);
|
||||
}
|
||||
|
||||
@ -21,6 +21,8 @@
|
||||
DisciplineCode: '',
|
||||
DisciplineName: '',
|
||||
Parent: null,
|
||||
SeqScr: 0,
|
||||
SeqRpt: 0,
|
||||
});
|
||||
let deleteConfirmOpen = $state(false);
|
||||
let deleteItem = $state(null);
|
||||
@ -30,6 +32,8 @@
|
||||
const columns = [
|
||||
{ key: 'DisciplineCode', label: 'Code', class: 'font-medium' },
|
||||
{ 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: 'actions', label: 'Actions', class: 'w-32 text-center' },
|
||||
];
|
||||
@ -73,7 +77,7 @@
|
||||
|
||||
function openCreateModal() {
|
||||
modalMode = 'create';
|
||||
formData = { DisciplineID: null, DisciplineCode: '', DisciplineName: '', Parent: null };
|
||||
formData = { DisciplineID: null, DisciplineCode: '', DisciplineName: '', Parent: null, SeqScr: 0, SeqRpt: 0 };
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
@ -84,6 +88,8 @@
|
||||
DisciplineCode: row.DisciplineCode,
|
||||
DisciplineName: row.DisciplineName,
|
||||
Parent: row.Parent || null,
|
||||
SeqScr: row.SeqScr ?? 0,
|
||||
SeqRpt: row.SeqRpt ?? 0,
|
||||
};
|
||||
modalOpen = true;
|
||||
}
|
||||
@ -97,6 +103,14 @@
|
||||
toastError('Discipline name is required');
|
||||
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;
|
||||
try {
|
||||
@ -271,6 +285,40 @@
|
||||
</select>
|
||||
<span class="label-text-alt text-xs text-gray-500">Optional parent for hierarchical structure</span>
|
||||
</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>
|
||||
{#snippet footer()}
|
||||
<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 DataTable from '$lib/components/DataTable.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 items = $state([]);
|
||||
@ -29,6 +29,17 @@
|
||||
let deleting = $state(false);
|
||||
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 = [
|
||||
{ key: 'SiteCode', label: 'Code', class: 'font-medium' },
|
||||
{ key: 'SiteName', label: 'Name' },
|
||||
@ -103,6 +114,10 @@
|
||||
toastError('Site code is required');
|
||||
return;
|
||||
}
|
||||
if (formData.SiteCode.trim().length !== 2) {
|
||||
toastError('Site code must be exactly 2 characters');
|
||||
return;
|
||||
}
|
||||
if (!formData.SiteName.trim()) {
|
||||
toastError('Site name is required');
|
||||
return;
|
||||
@ -239,15 +254,30 @@
|
||||
<span class="label-text text-sm font-medium">Site Code</span>
|
||||
<span class="label-text-alt text-xs text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="siteCode"
|
||||
type="text"
|
||||
class="input input-sm input-bordered w-full"
|
||||
bind:value={formData.SiteCode}
|
||||
placeholder="e.g., SITE001"
|
||||
required
|
||||
/>
|
||||
<span class="label-text-alt text-xs text-gray-500">Unique identifier for this site</span>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="siteCode"
|
||||
type="text"
|
||||
class="input input-sm input-bordered w-full pr-16"
|
||||
class:input-success={isSiteCodeValid}
|
||||
class:input-error={siteCodeLength > 0 && !isSiteCodeValid}
|
||||
bind:value={formData.SiteCode}
|
||||
placeholder="e.g., AB"
|
||||
maxlength="10"
|
||||
required
|
||||
/>
|
||||
<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 class="form-control">
|
||||
<label class="label" for="siteName">
|
||||
@ -275,7 +305,7 @@
|
||||
bind:value={formData.AccountID}
|
||||
>
|
||||
<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>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
@ -188,8 +188,14 @@
|
||||
FormulaInput: test.FormulaInput || '',
|
||||
FormulaCode: test.FormulaCode || '',
|
||||
members: test.testdefgrp?.map(m => ({
|
||||
TestSiteID: parseInt(m.TestSiteID),
|
||||
Member: parseInt(m.Member)
|
||||
TestSiteID: m.TestSiteID,
|
||||
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 || [],
|
||||
|
||||
@ -1,187 +1,201 @@
|
||||
<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 availableTests = $state([]);
|
||||
let selectedTestId = $state('');
|
||||
let addMemberOpen = $state(false);
|
||||
let searchQuery = $state('');
|
||||
|
||||
const members = $derived.by(() => {
|
||||
const memberIds = formData.details.members?.map(m => Number(m.TestSiteID)) || [];
|
||||
return tests.filter(t => memberIds.includes(Number(t.TestSiteID)))
|
||||
.map(t => {
|
||||
const memberObj = formData.details.members?.find(m => Number(m.TestSiteID) === Number(t.TestSiteID));
|
||||
return { ...t, seq: memberObj?.Member || 0 };
|
||||
})
|
||||
.sort((a, b) => a.seq - b.seq);
|
||||
const testdefgrp = formData.testdefgrp || formData.details?.members || [];
|
||||
return testdefgrp
|
||||
.map(m => ({
|
||||
TestSiteID: m.TestSiteID,
|
||||
TestSiteCode: m.TestSiteCode,
|
||||
TestSiteName: m.TestSiteName,
|
||||
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 memberIds = formData.details.members?.map(m => Number(m.TestSiteID)) || [];
|
||||
return tests.filter(t =>
|
||||
const availableTests = $derived.by(() => {
|
||||
const currentMembers = formData.testdefgrp || formData.details?.members || [];
|
||||
const memberIds = new Set(currentMembers.map(m => Number(m.TestSiteID)));
|
||||
|
||||
let filtered = tests.filter(t =>
|
||||
Number(t.TestSiteID) !== Number(formData.TestSiteID) &&
|
||||
!memberIds.includes(Number(t.TestSiteID)) &&
|
||||
!memberIds.has(Number(t.TestSiteID)) &&
|
||||
t.IsActive !== '0' &&
|
||||
t.IsActive !== 0
|
||||
).map(t => ({
|
||||
value: t.TestSiteID,
|
||||
label: `${t.TestSiteCode} - ${t.TestSiteName}`,
|
||||
data: t
|
||||
}));
|
||||
);
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
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() {
|
||||
isDirty = true;
|
||||
}
|
||||
|
||||
function addMember() {
|
||||
if (!selectedTestId) return;
|
||||
|
||||
const currentMembers = formData.details.members || [];
|
||||
function addMember(test) {
|
||||
const currentMembers = formData.testdefgrp || formData.details?.members || [];
|
||||
const newMember = {
|
||||
TestSiteID: parseInt(selectedTestId),
|
||||
Member: currentMembers.length + 1
|
||||
TestSiteID: test.TestSiteID,
|
||||
TestSiteCode: test.TestSiteCode || '',
|
||||
TestSiteName: test.TestSiteName || '',
|
||||
TestType: test.TestType || 'TEST',
|
||||
TestTypeLabel: test.TestTypeLabel || 'Test',
|
||||
SeqScr: test.SeqScr || '0'
|
||||
};
|
||||
formData.details.members = [...currentMembers, newMember];
|
||||
selectedTestId = '';
|
||||
|
||||
if (formData.hasOwnProperty('testdefgrp')) {
|
||||
formData.testdefgrp = [...currentMembers, newMember];
|
||||
} else {
|
||||
formData.details.members = [...currentMembers, newMember];
|
||||
}
|
||||
|
||||
handleFieldChange();
|
||||
}
|
||||
|
||||
function removeMember(testId) {
|
||||
const remainingMembers = formData.details.members?.filter(m => Number(m.TestSiteID) !== Number(testId)) || [];
|
||||
// Re-sequence the remaining members
|
||||
formData.details.members = remainingMembers.map((m, idx) => ({
|
||||
...m,
|
||||
Member: idx + 1
|
||||
}));
|
||||
handleFieldChange();
|
||||
}
|
||||
const isNewApi = formData.hasOwnProperty('testdefgrp');
|
||||
const currentMembers = formData.testdefgrp || formData.details?.members || [];
|
||||
const remainingMembers = currentMembers.filter(m => Number(m.TestSiteID) !== Number(testId));
|
||||
|
||||
function moveMember(index, direction) {
|
||||
const members = [...formData.details.members];
|
||||
const newIndex = index + direction;
|
||||
|
||||
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();
|
||||
if (isNewApi) {
|
||||
formData.testdefgrp = remainingMembers;
|
||||
} else {
|
||||
formData.details.members = remainingMembers;
|
||||
}
|
||||
handleFieldChange();
|
||||
}
|
||||
</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 class="space-y-4">
|
||||
<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>
|
||||
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-gray-600 uppercase tracking-wide">Current Members ({members.length})</h3>
|
||||
|
||||
{#if members.length === 0}
|
||||
<div class="text-center py-8 bg-base-200 rounded-lg">
|
||||
<Box class="w-12 h-12 mx-auto text-gray-400 mb-2" />
|
||||
<p class="text-sm text-gray-500">No members added yet</p>
|
||||
<p class="text-xs text-gray-400">Add tests to create this panel</p>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 h-[500px] overflow-hidden">
|
||||
<!-- Left Column: Available Tests -->
|
||||
<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 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>
|
||||
{:else}
|
||||
<div class="overflow-x-auto border border-base-200 rounded-lg">
|
||||
<table class="table table-sm table-compact">
|
||||
<thead>
|
||||
<tr class="bg-base-200">
|
||||
<th class="w-12 text-center">#</th>
|
||||
<th class="w-24">Code</th>
|
||||
<th>Name</th>
|
||||
<th class="w-20">Type</th>
|
||||
<th class="w-32 text-center">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each members as member, idx (member.TestSiteID)}
|
||||
<tr class="hover:bg-base-100">
|
||||
<td class="text-center text-gray-500">{idx + 1}</td>
|
||||
<td class="font-mono text-sm">{member.TestSiteCode}</td>
|
||||
<td>{member.TestSiteName}</td>
|
||||
<td>
|
||||
<span class="badge badge-xs badge-ghost">{member.TestType}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex justify-center gap-1">
|
||||
|
||||
<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>
|
||||
{:else}
|
||||
{#each availableTests as test (test.TestSiteID)}
|
||||
<div class="flex items-center justify-between p-2 hover:bg-base-200 rounded-md group">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-mono text-xs text-gray-500 w-8">{test.SeqScr || '-'}</span>
|
||||
<span class="font-mono text-sm font-medium truncate">{test.TestSiteCode}</span>
|
||||
<span class="badge badge-xs badge-ghost">{test.TestType}</span>
|
||||
</div>
|
||||
<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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each members as member (member.TestSiteID)}
|
||||
<tr class="hover:bg-base-200">
|
||||
<td class="text-center font-mono text-xs text-gray-600">{member.SeqScr}</td>
|
||||
<td class="font-mono text-xs">{member.TestSiteCode}</td>
|
||||
<td class="text-xs truncate max-w-[150px]" title={member.TestSiteName}>
|
||||
{member.TestSiteName}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-xs badge-ghost">{member.TestType}</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<button
|
||||
class="btn btn-ghost btn-xs"
|
||||
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"
|
||||
class="btn btn-ghost btn-xs text-error p-0 min-h-0 h-auto"
|
||||
onclick={() => removeMember(member.TestSiteID)}
|
||||
title="Remove Member"
|
||||
title="Remove"
|
||||
>
|
||||
<Trash2 class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="p-2 border-t border-base-300 text-xs text-gray-500 shrink-0">
|
||||
<p>Ordered by SeqScr value</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-gray-600 uppercase tracking-wide">Add Member</h3>
|
||||
|
||||
{#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>
|
||||
{: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>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-500 space-y-1">
|
||||
<p><strong>Tip:</strong> Members will display on reports in the order shown above.</p>
|
||||
<p><strong>Note:</strong> Panels cannot contain themselves or other panels (circular references).</p>
|
||||
<div class="text-xs text-gray-500">
|
||||
<p><strong>Tip:</strong> Members are automatically ordered by their sequence number. Click + to add, trash icon to remove.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user