Initial commit
This commit is contained in:
commit
d9cff2e70c
72 changed files with 2878 additions and 0 deletions
26
packages/client/src/routes/+layout.svelte
Normal file
26
packages/client/src/routes/+layout.svelte
Normal file
|
@ -0,0 +1,26 @@
|
|||
<script lang="ts">
|
||||
|
||||
import '../app.css';
|
||||
import ThemeSelector from '$lib/components/theme-selector.svelte';
|
||||
import { ModeWatcher } from 'mode-watcher';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
</script>
|
||||
|
||||
<ModeWatcher/>
|
||||
|
||||
<!-- navigation bar -->
|
||||
<header class="sticky top-0 right-0 left-0 flex justify-center z-50 backdrop-blur py-4 px-4 lg:px-8">
|
||||
<div class="flex items-center justify-between w-full max-w-7xl">
|
||||
<a href="/" class="cursor-pointer">
|
||||
<img class="w-24 hidden dark:block" src="logo_white.svg" alt="HNU Logo"/>
|
||||
<img class="w-24 block dark:hidden" src="logo_black.svg" alt="HNU Logo"/>
|
||||
</a>
|
||||
<div class="flex space-x-2">
|
||||
<ThemeSelector/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{@render children()}
|
298
packages/client/src/routes/+page.svelte
Normal file
298
packages/client/src/routes/+page.svelte
Normal file
|
@ -0,0 +1,298 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronRightIcon,
|
||||
CopyIcon,
|
||||
Loader2Icon,
|
||||
MessageSquareIcon,
|
||||
SendIcon,
|
||||
UnplugIcon,
|
||||
UserIcon,
|
||||
} from '@lucide/svelte';
|
||||
import { ConnectionState, type Message, MessageType, type ReceiveHl7v2Message } from '@hnu.de/hl7v2-shared';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Card, CardContent, CardHeader } from '$lib/components/ui/card';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/dropdown-menu';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '$lib/components/ui/tooltip';
|
||||
|
||||
// connection state
|
||||
let ws = $state<WebSocket | undefined>(undefined);
|
||||
let userId = $state<string | undefined>(undefined);
|
||||
let connectionState = $state<ConnectionState>(ConnectionState.disconnected);
|
||||
|
||||
// client state
|
||||
let composedMessage = $state('');
|
||||
let sentMessages = $state<ReceiveHl7v2Message[]>([]); // storing sent messages with client-timestamp for now
|
||||
let receivedMessages = $state<ReceiveHl7v2Message[]>([]);
|
||||
let isSending = $state(false);
|
||||
let copySuccess = $state(false);
|
||||
let deliveryError = $state('');
|
||||
|
||||
// segment presets
|
||||
const segmentTemplates = {
|
||||
MSH: {
|
||||
name: 'Message Header',
|
||||
template: () => `MSH|^~\\&|SENDER_APP|SENDER_FACILITY|RECIPIENT_USER_ID|RECEIVER_FACILITY|${new Date().toISOString().replace(/[-:.]/g, '').slice(0, 14)}||ADT^A01|${Date.now()}|P|2.3`,
|
||||
},
|
||||
PID: {
|
||||
name: 'Patient Identification',
|
||||
template: () => `PID|||PATIENT_MRN||Doe^John^J||19900101|M`,
|
||||
},
|
||||
PV1: {
|
||||
name: 'Patient Visit',
|
||||
template: () => `PV1||I|ER^101^A|||1234^Welby^Marcus`,
|
||||
},
|
||||
AL1: {
|
||||
name: 'Patient Allergy Information',
|
||||
template: () => 'AL1|1|DA|12345^Penicillin|SV|Hives',
|
||||
},
|
||||
ITM: {
|
||||
name: 'Material Item',
|
||||
template: () => 'ITM|1|ITEM-789|Gauze Pads|Sterile Gauze Pads 4x4',
|
||||
},
|
||||
IVC: {
|
||||
name: 'Invoice',
|
||||
template: () => 'IVC|INV-987|ACCT-654|500.00|20230201',
|
||||
},
|
||||
};
|
||||
const segmentTypes = Object.keys(segmentTemplates) as Array<keyof typeof segmentTemplates>;
|
||||
|
||||
function connectToServer() {
|
||||
|
||||
console.log('Connecting to server...');
|
||||
connectionState = ConnectionState.connecting;
|
||||
|
||||
const socket = new WebSocket(`wss://${env.PUBLIC_SERVER}`);
|
||||
|
||||
socket.onopen = () => {
|
||||
console.log('WebSocket connection established.');
|
||||
ws = socket;
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
|
||||
const message = JSON.parse(event.data) as Message;
|
||||
console.log('Message received from server:', message);
|
||||
|
||||
switch (message.type) {
|
||||
|
||||
// initial message from server assigning ID
|
||||
case MessageType.assign_id:
|
||||
userId = message.payload.userId;
|
||||
composedMessage = segmentTemplates.MSH.template();
|
||||
connectionState = ConnectionState.connected;
|
||||
break;
|
||||
|
||||
// message from another client
|
||||
case MessageType.receive_hl7v2:
|
||||
receivedMessages = [message, ...receivedMessages];
|
||||
break;
|
||||
|
||||
// message from server due to delivery error
|
||||
case MessageType.delivery_error:
|
||||
deliveryError = message.payload.error;
|
||||
setTimeout(() => deliveryError = '', 5000); // Clear error after 5 seconds
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
console.log('WebSocket connection closed.');
|
||||
connectionState = ConnectionState.disconnected;
|
||||
ws = undefined;
|
||||
};
|
||||
|
||||
socket.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
connectionState = ConnectionState.disconnected;
|
||||
ws = undefined;
|
||||
};
|
||||
}
|
||||
|
||||
// clean up on close
|
||||
$effect(() => {
|
||||
return () => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
connectionState = ConnectionState.disconnected;
|
||||
ws.close();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
function addSegment(type: keyof typeof segmentTemplates) {
|
||||
const template = segmentTemplates[type].template();
|
||||
composedMessage += `\r\n${template}`;
|
||||
}
|
||||
|
||||
function handleSendMessage(message: string) {
|
||||
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN || isSending || !userId) {
|
||||
console.log('Socket not ready');
|
||||
return;
|
||||
}
|
||||
|
||||
isSending = true;
|
||||
deliveryError = '';
|
||||
|
||||
const messageToSend = {
|
||||
type: 'send_hl7v2',
|
||||
payload: { message },
|
||||
} as Message;
|
||||
ws.send(JSON.stringify(messageToSend));
|
||||
|
||||
sentMessages = [{
|
||||
type: MessageType.receive_hl7v2,
|
||||
payload: { message, timestamp: new Date().toISOString() },
|
||||
} as ReceiveHl7v2Message, ...sentMessages];
|
||||
composedMessage = segmentTemplates.MSH.template();
|
||||
isSending = false;
|
||||
}
|
||||
|
||||
function copyUserId() {
|
||||
if (userId) {
|
||||
navigator.clipboard.writeText(userId).then(() => {
|
||||
copySuccess = true;
|
||||
setTimeout(() => copySuccess = false, 2000);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
{#snippet message(msg: ReceiveHl7v2Message)}
|
||||
<div class="bg-foreground/10 p-3 rounded-md space-y-1">
|
||||
<p class="text-xs">{new Date(msg.payload.timestamp).toLocaleString()}</p>
|
||||
<pre class="text-sm whitespace-pre-wrap break-all">{msg.payload.message}</pre>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
|
||||
<div class="p-4 lg:p-8">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<main class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
<!-- Left Column: User Info & Logs -->
|
||||
<div class="space-y-6">
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center">
|
||||
<UserIcon class="mr-2"/>
|
||||
Your Info
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if connectionState === ConnectionState.connected && userId}
|
||||
<Label class="">Station ID</Label>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Input disabled bind:value={userId}/>
|
||||
<Button variant="outline" size="icon" onclick={copyUserId}>
|
||||
{#if copySuccess}
|
||||
<CheckIcon class={'text-green-400'}/>
|
||||
{:else}
|
||||
<CopyIcon/>
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
{:else if connectionState === ConnectionState.disconnected}
|
||||
<div class="flex items-center space-x-2">
|
||||
<UnplugIcon/>
|
||||
<span>Disconnected</span>
|
||||
</div>
|
||||
{:else if connectionState === ConnectionState.connecting}
|
||||
<div class="flex items-center space-x-2">
|
||||
<Loader2Icon class="animate-spin"/>
|
||||
<span>Connecting...</span>
|
||||
</div>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader class="flex items-center space-x-2">
|
||||
<ChevronRightIcon class="text-green-400"/>
|
||||
<span>Received Messages</span>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2">
|
||||
{#if receivedMessages.length === 0}
|
||||
<p class="text-foreground/70 italic">Waiting for incoming messages...</p>
|
||||
{/if}
|
||||
{#each receivedMessages as msg (msg.payload.timestamp)}
|
||||
{@render message(msg)}
|
||||
{/each}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Composer and Sent Log -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<Card>
|
||||
<CardHeader class="flex items-center space-x-2">
|
||||
<MessageSquareIcon/>
|
||||
<span>HL7v2 Message Editor</span>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if connectionState === ConnectionState.connected && userId}
|
||||
<div>
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
{#each segmentTypes as type (type)}
|
||||
{#if type !== 'MSH'}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button onclick={() => addSegment(type)}>
|
||||
+ {type}
|
||||
</Button>
|
||||
</TooltipTrigger
|
||||
>
|
||||
<TooltipContent>
|
||||
{segmentTemplates[type].name}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<textarea bind:value={composedMessage}
|
||||
class="w-full h-64 bg-black font-mono text-sm text-green-400 p-4 rounded-md"></textarea>
|
||||
<Button onclick={() => handleSendMessage(composedMessage)}
|
||||
disabled={isSending || !composedMessage} class="w-full space-x-2">
|
||||
<SendIcon/>
|
||||
<span>Send Message</span>
|
||||
</Button>
|
||||
{#if deliveryError}
|
||||
<p class="text-red-600 dark:text-red-400 text-center mt-2">{deliveryError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if connectionState === ConnectionState.disconnected}
|
||||
<div class="flex flex-col items-center justify-center min-h-96 text-foreground/70 space-y-4">
|
||||
<h3 class="text-xl font-bold">Connect as a client</h3>
|
||||
<Button onclick={connectToServer}>Connect</Button>
|
||||
</div>
|
||||
{:else if connectionState === ConnectionState.connecting}
|
||||
<div class="flex flex-col items-center justify-center min-h-96 text-foreground/70">
|
||||
<Loader2Icon class="animate-spin mb-4" size={48}/>
|
||||
<h3 class="text-xl font-bold">Connecting...</h3>
|
||||
</div>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader class="flex items-center space-x-2">
|
||||
<SendIcon class="text-blue-400"/>
|
||||
<span>Sent Messages Log</span>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2">
|
||||
{#if sentMessages.length === 0}
|
||||
<p class="text-foreground/70 italic">Your sent messages will appear here.</p>
|
||||
{/if}
|
||||
{#each sentMessages as msg (msg.payload.timestamp)}
|
||||
{@render message(msg)}
|
||||
{/each}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
Loading…
Add table
Add a link
Reference in a new issue