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:
mahdahar 2026-03-10 16:40:44 +07:00
parent 695ee3de91
commit 22ee1ebfd1
7 changed files with 701 additions and 163 deletions

View File

@ -125,3 +125,8 @@ language_backend:
# list of regex patterns which, when matched, mark a memory entry as readonly. # list of regex patterns which, when matched, mark a memory entry as readonly.
# 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:

View File

@ -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:

View File

@ -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);
} }

View File

@ -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>

View File

@ -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>
<input <div class="relative">
id="siteCode" <input
type="text" id="siteCode"
class="input input-sm input-bordered w-full" type="text"
bind:value={formData.SiteCode} class="input input-sm input-bordered w-full pr-16"
placeholder="e.g., SITE001" class:input-success={isSiteCodeValid}
required class:input-error={siteCodeLength > 0 && !isSiteCodeValid}
/> bind:value={formData.SiteCode}
<span class="label-text-alt text-xs text-gray-500">Unique identifier for this site</span> 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>
<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>

View File

@ -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 || [],

View File

@ -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'
}; };
formData.details.members = [...currentMembers, newMember];
selectedTestId = ''; if (formData.hasOwnProperty('testdefgrp')) {
formData.testdefgrp = [...currentMembers, newMember];
} else {
formData.details.members = [...currentMembers, newMember];
}
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"> <div class="space-y-4">
<h2 class="text-lg font-semibold text-gray-800">Group Members</h2> <div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-800">Group Members</h2>
<div class="alert alert-info text-sm"> <span class="badge badge-sm badge-ghost">{members.length} selected</span>
<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>
<div class="space-y-4"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-4 h-[500px] overflow-hidden">
<h3 class="text-sm font-medium text-gray-600 uppercase tracking-wide">Current Members ({members.length})</h3> <!-- Left Column: Available Tests -->
<div class="flex flex-col border border-base-300 rounded-lg bg-base-100 overflow-hidden">
{#if members.length === 0} <div class="p-3 border-b border-base-300 bg-base-200 rounded-t-lg shrink-0">
<div class="text-center py-8 bg-base-200 rounded-lg"> <h3 class="text-sm font-medium text-gray-700 mb-2">Available Tests</h3>
<Box class="w-12 h-12 mx-auto text-gray-400 mb-2" /> <label class="input input-sm input-bordered flex items-center gap-2 w-full">
<p class="text-sm text-gray-500">No members added yet</p> <Search class="w-4 h-4 text-gray-400" />
<p class="text-xs text-gray-400">Add tests to create this panel</p> <input
type="text"
class="grow bg-transparent outline-none text-sm"
placeholder="Search by code or name..."
bind:value={searchQuery}
/>
</label>
</div> </div>
{:else}
<div class="overflow-x-auto border border-base-200 rounded-lg"> <div class="flex-1 overflow-y-auto p-2 space-y-1 min-h-0">
<table class="table table-sm table-compact"> {#if availableTests.length === 0}
<thead> <div class="text-center py-8 text-gray-500">
<tr class="bg-base-200"> <Box class="w-10 h-10 mx-auto mb-2 opacity-50" />
<th class="w-12 text-center">#</th> <p class="text-sm">No tests available</p>
<th class="w-24">Code</th> <p class="text-xs opacity-70">
<th>Name</th> {searchQuery ? 'Try a different search term' : 'All tests are already added'}
<th class="w-20">Type</th> </p>
<th class="w-32 text-center">Actions</th> </div>
</tr> {:else}
</thead> {#each availableTests as test (test.TestSiteID)}
<tbody> <div class="flex items-center justify-between p-2 hover:bg-base-200 rounded-md group">
{#each members as member, idx (member.TestSiteID)} <div class="flex-1 min-w-0">
<tr class="hover:bg-base-100"> <div class="flex items-center gap-2">
<td class="text-center text-gray-500">{idx + 1}</td> <span class="font-mono text-xs text-gray-500 w-8">{test.SeqScr || '-'}</span>
<td class="font-mono text-sm">{member.TestSiteCode}</td> <span class="font-mono text-sm font-medium truncate">{test.TestSiteCode}</span>
<td>{member.TestSiteName}</td> <span class="badge badge-xs badge-ghost">{test.TestType}</span>
<td> </div>
<span class="badge badge-xs badge-ghost">{member.TestType}</span> <p class="text-xs text-gray-600 truncate pl-10">{test.TestSiteName}</p>
</td> </div>
<td> <button
<div class="flex justify-center gap-1"> 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 <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> {/if}
</div> </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>
<div class="space-y-4"> <div class="text-xs text-gray-500">
<h3 class="text-sm font-medium text-gray-600 uppercase tracking-wide">Add Member</h3> <p><strong>Tip:</strong> Members are automatically ordered by their sequence number. Click + to add, trash icon to remove.</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>
{: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> </div>
</div> </div>