From 8f75a1339cd33968621a8ca623a7130b94544e23 Mon Sep 17 00:00:00 2001 From: mahdahar <89adham@gmail.com> Date: Tue, 24 Feb 2026 06:12:17 +0700 Subject: [PATCH] feat(testmap): update test map page and modal components --- AGENTS.md | 250 +++++++----------- .../(app)/master-data/testmap/+page.svelte | 155 +++++++++-- .../master-data/testmap/TestMapModal.svelte | 95 ++++--- 3 files changed, 291 insertions(+), 209 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 195984a..3cb17f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,114 +1,79 @@ # AGENTS.md - Coding Guidelines for CLQMS Frontend -## Project Overview +SvelteKit frontend for Clinical Laboratory Quality Management System. Uses Svelte 5 runes, TailwindCSS 4, DaisyUI, and Lucide icons. -SvelteKit frontend for Clinical Laboratory Quality Management System (CLQMS). Uses Svelte 5 runes, TailwindCSS 4, DaisyUI, and Lucide icons. - -## Build Commands +## Commands ```bash -# Development server -pnpm run dev - -# Production build -pnpm run build - -# Preview production build -pnpm run preview - -# Sync SvelteKit (runs automatically on install) -pnpm run prepare +pnpm run dev # Development server +pnpm run build # Production build +pnpm run preview # Preview production build +pnpm run prepare # Sync SvelteKit ``` -## Testing +**No test framework yet.** When adding: use Vitest (`vitest run src/path/to/test.js`), Playwright for E2E. -**No test framework configured yet.** When adding tests: -- Use Vitest for unit tests (recommended with SvelteKit) -- Use Playwright for E2E tests -- Run single test: `vitest run src/path/to/test.js` -- Run tests in watch mode: `vitest` +## Code Style -## Code Style Guidelines - -### JavaScript/TypeScript - -- **ES Modules**: Always use `import`/`export` (type: "module" in package.json) -- **Semicolons**: Use semicolons consistently -- **Quotes**: Use single quotes for strings +- **ES Modules**: `import`/`export` (type: "module") +- **Semicolons**: Required +- **Quotes**: Single quotes - **Indentation**: 2 spaces -- **Trailing commas**: Use in multi-line objects/arrays -- **JSDoc**: Document all exported functions with JSDoc comments +- **Trailing commas**: In multi-line objects/arrays +- **JSDoc**: Document all exported functions -### Svelte Components +## Naming Conventions + +- **Components**: PascalCase (`LoginForm.svelte`) +- **Files/Routes**: lowercase with hyphens (`+page.svelte`) +- **Variables**: camelCase (`isLoading`, `userName`) +- **Constants**: UPPER_SNAKE_CASE (`API_URL`) +- **Stores**: camelCase (`auth`, `config`) +- **Handlers**: prefix with `handle` (`handleSubmit`) +- **Form state**: `formLoading`, `formError` + +## Imports Order + +1. Svelte (`svelte`, `$app/*`) +2. `$lib/*` (stores, api, components, utils) +3. External libraries (`lucide-svelte`) +4. Relative imports (minimize, prefer `$lib`) + +## Svelte 5 Components ```svelte ``` -### Naming Conventions - -- **Components**: PascalCase (`LoginForm.svelte`, `PatientFormModal.svelte`) -- **Files/Routes**: lowercase with hyphens (`+page.svelte`, `user-profile/`) -- **Variables**: camelCase (`isLoading`, `userName`) -- **Constants**: UPPER_SNAKE_CASE (`API_URL`, `STORAGE_KEY`) -- **Stores**: camelCase, descriptive (`auth`, `userStore`) -- **Event handlers**: prefix with `handle` (`handleSubmit`, `handleClick`) -- **Form state**: `formLoading`, `formError`, `deleteConfirmOpen` - -### Imports Order - -1. Svelte imports (`svelte`, `$app/*`) -2. $lib aliases (`$lib/stores/*`, `$lib/api/*`, `$lib/components/*`, `$lib/utils/*`) -3. External libraries (`lucide-svelte`) -4. Relative imports (minimize these, prefer `$lib`) - -### Project Structure - -``` -src/ - lib/ - api/ # API client and endpoints (per feature) - stores/ # Svelte stores (auth, config, valuesets) - components/ # Reusable components (DataTable, Modal, Sidebar) - utils/ # Utilities (toast, helpers) - assets/ # Static assets - routes/ # SvelteKit routes - (app)/ # Route groups (protected) - login/ - dashboard/ -``` - -### API Client Patterns +## API Patterns ```javascript -// src/lib/api/client.js - Base client handles auth, 401 redirects -import { apiClient, get, post, put, patch, del } from '$lib/api/client.js'; +// src/lib/api/client.js - Use these helpers +import { get, post, put, patch, del } from '$lib/api/client.js'; -// src/lib/api/feature.js - Feature-specific endpoints with JSDoc +// Feature endpoints (with JSDoc) export async function fetchItems(params = {}) { const query = new URLSearchParams(params).toString(); return get(query ? `/api/items?${query}` : '/api/items'); @@ -118,8 +83,8 @@ export async function createItem(data) { return post('/api/items', data); } -export async function updateItem(data) { - return patch('/api/items', data); +export async function updateItem(id, data) { + return patch(`/api/items/${id}`, data); } export async function deleteItem(id) { @@ -127,45 +92,46 @@ export async function deleteItem(id) { } ``` -### Store Patterns +## Store Patterns ```javascript -// Use writable for stores with localStorage persistence import { writable } from 'svelte/store'; import { browser } from '$app/environment'; function createStore() { const getInitialState = () => { if (!browser) return { data: null }; - return { data: JSON.parse(localStorage.getItem('key')) }; + return JSON.parse(localStorage.getItem('key')); }; - const { subscribe, set, update } = writable(getInitialState()); + const { subscribe, set } = writable(getInitialState()); return { subscribe, setData: (data) => { if (browser) localStorage.setItem('key', JSON.stringify(data)); - set({ data }); + set(data); } }; } ``` -### Component Patterns +## Component Patterns ```svelte - -let { open = $bindable(false), selected = $bindable(null) } = $props(); + + + {#snippet children()} +
...
+ {/snippet} + {#snippet footer()} + + {/snippet} +
- -{@render children?.()} -{@render footer()} - - - + + ``` -### Error Handling +## Error Handling ```javascript try { const result = await api.fetchData(); - toastSuccess('Operation successful'); + success('Operation successful'); } catch (err) { const message = err.message || 'An unexpected error occurred'; - toastError(message); + error(message); console.error('Operation failed:', err); } ``` -### Styling with Tailwind & DaisyUI - -- DaisyUI components: `btn`, `card`, `alert`, `input`, `navbar`, `modal`, `dropdown`, `menu` -- Color scheme: `primary` (emerald), `secondary` (dark blue), `accent` (royal blue) -- Custom compact classes: `.compact-y`, `.compact-p`, `.compact-input`, `.compact-btn`, `.compact-card` -- Size modifiers: `.btn-sm`, `.input-sm`, `.select-sm` for compact forms - -### Authentication Patterns - -- Auth state in `$lib/stores/auth.js` using writable store -- Check `$auth.isAuthenticated` in layout `onMount` -- Redirect to `/login` if unauthenticated using `goto('/login')` -- API client auto-redirects on 401 responses - -### Runtime Config Pattern +## Form Patterns ```javascript -// Use runtime config for API URL that can be changed at runtime -import { config } from '$lib/stores/config.js'; -function getApiUrl() { - return config.getApiUrl() || import.meta.env.VITE_API_URL || ''; -} -``` - -### LocalStorage - -- Only access in browser: check `browser` from `$app/environment` -- Use descriptive keys: `clqms_username`, `clqms_remember`, `auth_token` - -### Form Handling Patterns - -```javascript -// Form state with validation let formLoading = $state(false); let formError = $state(''); let formData = $state({ username: '', password: '' }); @@ -238,7 +174,7 @@ async function handleSubmit() { formLoading = true; try { await api.submit(formData); - toastSuccess('Success'); + success('Success'); } catch (err) { formError = err.message; } finally { @@ -247,31 +183,43 @@ async function handleSubmit() { } ``` -### Toast/Notification System +## Styling (DaisyUI + Tailwind) -```javascript -import { success, error, info, warning } from '$lib/utils/toast.js'; +- **Components**: `btn`, `card`, `alert`, `input`, `navbar`, `modal`, `dropdown` +- **Colors**: `primary` (emerald), `secondary` (dark blue), `accent` (royal blue) +- **Sizes**: `btn-sm`, `input-sm`, `select-sm` for compact forms +- **Custom**: `.compact-y`, `.compact-p`, `.compact-input` + +## Authentication + +- Check `$auth.isAuthenticated` in layout `onMount` +- Redirect to `/login` if unauthenticated using `goto('/login')` +- API client auto-redirects on 401 + +## LocalStorage + +- Only access in browser: check `browser` from `$app/environment` +- Use descriptive keys: `clqms_username`, `auth_token` + +## Project Structure -// Use toast notifications for user feedback -success('Item created successfully'); -error('Failed to create item'); -info('New message received'); -warning('Action requires confirmation'); ``` - -## Proxy Configuration - -API requests to `/api` are proxied to `http://localhost:8000` in dev. See `vite.config.js`. - -## API Documentation - -API Reference (Swagger UI): https://clqms01-api.services-summit.my.id/swagger/ +src/ + lib/ + api/ # API clients per feature + stores/ # Svelte stores + components/ # Reusable components + utils/ # Utilities (toast, helpers) + routes/ # SvelteKit routes + (app)/ # Route groups (protected) + login/ + dashboard/ +``` ## Important Notes -- No ESLint or Prettier configured yet - add if needed -- No test framework configured yet -- Uses Svelte 5 runes: `$props`, `$state`, `$derived`, `$effect`, `$bindable` -- SvelteKit file-based routing with `+page.svelte`, `+layout.svelte` +- Uses Svelte 5: `$props`, `$state`, `$derived`, `$effect`, `$bindable` - Static adapter configured for static export - Runtime config allows API URL changes without rebuild +- API proxy: `/api` → `http://localhost:8000` (dev) +- API Docs: https://clqms01-api.services-summit.my.id/swagger/ diff --git a/src/routes/(app)/master-data/testmap/+page.svelte b/src/routes/(app)/master-data/testmap/+page.svelte index 478f811..e8bfb4e 100644 --- a/src/routes/(app)/master-data/testmap/+page.svelte +++ b/src/routes/(app)/master-data/testmap/+page.svelte @@ -19,6 +19,7 @@ Monitor, Filter, X, + FileText, } from 'lucide-svelte'; let loading = $state(false); @@ -27,9 +28,11 @@ let modalOpen = $state(false); let modalMode = $state('create'); let modalData = $state(null); + let modalGroupData = $state(null); let deleting = $state(false); let deleteConfirmOpen = $state(false); let deleteItem = $state(null); + let deleteGroupMode = $state(false); // Filter states let filterHostType = $state(''); @@ -38,28 +41,57 @@ let filterClientID = $state(''); const columns = [ - { key: 'HostType', label: 'Host Type', class: 'w-20' }, - { key: 'HostID', label: 'Host ID', class: 'w-32' }, - { key: 'ClientType', label: 'Client Type', class: 'w-24' }, - { key: 'ClientID', label: 'Client ID', class: 'w-32' }, - { key: 'actions', label: 'Actions', class: 'w-24 text-center' }, + { key: 'HostInfo', label: 'Host System', class: 'w-48' }, + { key: 'ClientInfo', label: 'Client System', class: 'w-48' }, + { key: 'TestCount', label: 'Tests', class: 'w-24 text-center' }, + { key: 'TestPreview', label: 'Test Codes', class: 'flex-1' }, + { key: 'actions', label: 'Actions', class: 'w-32 text-center' }, ]; - // Derived filtered test maps - let filteredTestMaps = $derived( - testMaps.filter((mapping) => { + // Group test mappings by HostType/HostID/ClientType/ClientID + let groupedTestMaps = $derived(() => { + const groups = new Map(); + + testMaps.forEach((mapping) => { + const key = `${mapping.HostType || ''}|${mapping.HostID || ''}|${mapping.ClientType || ''}|${mapping.ClientID || ''}`; + + if (!groups.has(key)) { + groups.set(key, { + key, + HostType: mapping.HostType || '', + HostID: mapping.HostID || '', + ClientType: mapping.ClientType || '', + ClientID: mapping.ClientID || '', + mappings: [], + testCodes: [], + }); + } + + const group = groups.get(key); + group.mappings.push(mapping); + if (mapping.HostTestCode) { + group.testCodes.push(mapping.HostTestCode); + } + }); + + return Array.from(groups.values()); + }); + + // Derived filtered grouped test maps + let filteredGroupedTestMaps = $derived( + groupedTestMaps().filter((group) => { const matchesHostType = !filterHostType || - (mapping.HostType && mapping.HostType.toLowerCase().includes(filterHostType.toLowerCase())); + (group.HostType && group.HostType.toLowerCase().includes(filterHostType.toLowerCase())); const matchesHostID = !filterHostID || - (mapping.HostID && mapping.HostID.toLowerCase().includes(filterHostID.toLowerCase())); + (group.HostID && group.HostID.toLowerCase().includes(filterHostID.toLowerCase())); const matchesClientType = !filterClientType || - (mapping.ClientType && mapping.ClientType.toLowerCase().includes(filterClientType.toLowerCase())); + (group.ClientType && group.ClientType.toLowerCase().includes(filterClientType.toLowerCase())); const matchesClientID = !filterClientID || - (mapping.ClientID && mapping.ClientID.toLowerCase().includes(filterClientID.toLowerCase())); + (group.ClientID && group.ClientID.toLowerCase().includes(filterClientID.toLowerCase())); return matchesHostType && matchesHostID && matchesClientType && matchesClientID; }) @@ -95,12 +127,15 @@ function openCreateModal() { modalMode = 'create'; modalData = null; + modalGroupData = null; modalOpen = true; } - function openEditModal(row) { + function openEditGroupModal(group) { modalMode = 'edit'; - modalData = row; + modalGroupData = group; + // Pass the first mapping as initial data, modal will handle the rest + modalData = group.mappings[0] || null; modalOpen = true; } @@ -108,21 +143,33 @@ loadTestMaps(); } - function confirmDelete(row) { - deleteItem = row; + function confirmDeleteGroup(group) { + deleteItem = group; + deleteGroupMode = true; deleteConfirmOpen = true; } async function handleDelete() { deleting = true; try { - await deleteTestMap(deleteItem.TestMapID); - toastSuccess('Test mapping deleted successfully'); + if (deleteGroupMode && deleteItem) { + // Delete all mappings in the group + const deletePromises = deleteItem.mappings.map((mapping) => + deleteTestMap(mapping.TestMapID) + ); + await Promise.all(deletePromises); + toastSuccess(`Deleted ${deleteItem.mappings.length} test mapping(s) successfully`); + } else if (deleteItem?.TestMapID) { + // Delete single mapping (fallback) + await deleteTestMap(deleteItem.TestMapID); + toastSuccess('Test mapping deleted successfully'); + } deleteConfirmOpen = false; deleteItem = null; + deleteGroupMode = false; await loadTestMaps(); } catch (err) { - toastError(err.message || 'Failed to delete test mapping'); + toastError(err.message || 'Failed to delete test mapping(s)'); } finally { deleting = false; } @@ -134,6 +181,16 @@ filterClientType = ''; filterClientID = ''; } + + function getTestCodesPreview(testCodes, maxCount = 3) { + if (!testCodes || testCodes.length === 0) return '-'; + const displayCodes = testCodes.slice(0, maxCount); + const remaining = testCodes.length - maxCount; + if (remaining > 0) { + return `${displayCodes.join(', ')} +${remaining} more`; + } + return displayCodes.join(', '); + }
@@ -213,7 +270,7 @@
- {#if !loading && filteredTestMaps.length === 0} + {#if !loading && filteredGroupedTestMaps.length === 0}
@@ -247,26 +304,55 @@ {:else} {#snippet cell({ column, row, value })} - {#if column.key === 'actions'} + {#if column.key === 'HostInfo'} +
+ +
+
{row.HostType || '-'}
+
{row.HostID || '-'}
+
+
+ {:else if column.key === 'ClientInfo'} +
+ +
+
{row.ClientType || '-'}
+
{row.ClientID || '-'}
+
+
+ {:else if column.key === 'TestCount'} +
+ + {row.mappings.length} + +
+ {:else if column.key === 'TestPreview'} +
+ + + {getTestCodesPreview(row.testCodes)} + +
+ {:else if column.key === 'actions'}
@@ -285,14 +371,21 @@ bind:open={modalOpen} mode={modalMode} initialData={modalData} + groupData={modalGroupData} {containers} onSave={handleModalSave} /> - +
-

Are you sure you want to delete this test mapping?

+

+ {#if deleteGroupMode} + Are you sure you want to delete all {deleteItem?.mappings?.length || 0} test mapping(s) in this group? + {:else} + Are you sure you want to delete this test mapping? + {/if} +

Host: @@ -302,6 +395,12 @@ Client: {deleteItem?.ClientType} / {deleteItem?.ClientID}

+ {#if deleteGroupMode && deleteItem?.testCodes?.length > 0} +

+ Tests: + {deleteItem.testCodes.slice(0, 5).join(', ')}{deleteItem.testCodes.length > 5 ? '...' : ''} +

+ {/if}

diff --git a/src/routes/(app)/master-data/testmap/TestMapModal.svelte b/src/routes/(app)/master-data/testmap/TestMapModal.svelte index b795d5d..8e421b5 100644 --- a/src/routes/(app)/master-data/testmap/TestMapModal.svelte +++ b/src/routes/(app)/master-data/testmap/TestMapModal.svelte @@ -8,9 +8,10 @@ Server, Monitor, FlaskConical, + AlertCircle, } from 'lucide-svelte'; - let { open = $bindable(false), mode = 'create', initialData = null, containers = [], onSave } = $props(); + let { open = $bindable(false), mode = 'create', initialData = null, groupData = null, containers = [], onSave } = $props(); let saving = $state(false); @@ -41,7 +42,27 @@ function initializeModal() { formErrors = {}; - if (mode === 'edit' && initialData) { + if (mode === 'edit' && groupData) { + // Edit mode with group data - load all mappings in the group + modalContext = { + HostType: groupData.HostType || '', + HostID: groupData.HostID || '', + ClientType: groupData.ClientType || '', + ClientID: groupData.ClientID || '', + }; + + // Load all mappings from the group + modalRows = groupData.mappings.map((mapping) => ({ + TestMapID: mapping.TestMapID, + HostTestCode: mapping.HostTestCode || '', + HostTestName: mapping.HostTestName || '', + ConDefID: mapping.ConDefID || null, + ClientTestCode: mapping.ClientTestCode || '', + ClientTestName: mapping.ClientTestName || '', + isNew: false, + })); + } else if (mode === 'edit' && initialData) { + // Legacy edit mode (single mapping) modalContext = { HostType: initialData.HostType || '', HostID: initialData.HostID || '', @@ -58,6 +79,7 @@ isNew: false, }]; } else { + // Create mode modalContext = { HostType: '', HostID: '', @@ -184,12 +206,25 @@ -

- -
+
+ +
+ + {#if mode === 'edit' && groupData} +
+ +
+ Editing {modalRows.length} test mapping(s) + in this host-client group +
+
+ {/if} + + +

@@ -289,8 +324,8 @@

- -
+ +

Test Mappings @@ -298,40 +333,40 @@

- +
- - - - - - + + + + + + {#each modalRows as row, index (index)} - - - - - -
Host Test CodeHost Test NameContainerClient Test CodeClient Test NameHost Test CodeHost Test NameContainerClient Test CodeClient Test Name
+ + + {#if modalContext.ClientType === 'INST'} + {/if} + + +