HL7v2/packages/client/src/routes/+page.svelte

319 lines
13 KiB
Svelte

<script lang="ts">
import {
CheckIcon,
ChevronRightIcon,
CopyIcon,
Loader2Icon,
MessageSquareIcon,
SendIcon,
UnplugIcon,
UserIcon,
} from '@lucide/svelte';
import {
ConnectionState,
type DeliverySuccessMessage,
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';
import { dev } from '$app/environment';
import { toast } from 'svelte-sonner';
// connection state
let ws = $state<WebSocket | undefined>(undefined); // websocket client
let stationId = $state<string | undefined>(undefined); // stationId assigned by server
let connectionState = $state<ConnectionState>(ConnectionState.disconnected);
// client state
let composedMessage = $state(''); // content of text-box
let sentMessages = $state<DeliverySuccessMessage[]>([]);
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>;
// handles the connection process and message handling
function connectToServer() {
console.log('Connecting to server...');
connectionState = ConnectionState.connecting;
const socket = new WebSocket(`${dev ? 'ws' : 'wss'}://${env.PUBLIC_SERVER}`);
// store websocket on successful connection
socket.onopen = () => {
console.log('WebSocket connection established.');
ws = socket;
isSending = false
};
// register message handlers
socket.onmessage = (event) => {
// parse every message as type Message
const message = JSON.parse(event.data) as Message;
console.log('Message received from server:', message);
// react based on message type
switch (message.type) {
// initial message from server assigning ID
case MessageType.assign_id:
stationId = message.payload.stationId;
composedMessage = segmentTemplates.MSH.template();
connectionState = ConnectionState.connected;
break;
// message from another client
case MessageType.receive_hl7v2:
receivedMessages = [message, ...receivedMessages];
break;
// our message was successfully delivered
case MessageType.delivery_success:
sentMessages = [message, ...sentMessages];
toast.success("Message delivered successfully")
isSending = false
break;
// message from server due to delivery error
case MessageType.delivery_error:
deliveryError = message.payload.error;
toast.error(deliveryError);
isSending = false
break;
}
};
// reset connection state and websocket client on disconnect
socket.onclose = () => {
console.log('WebSocket connection closed.');
connectionState = ConnectionState.disconnected;
ws = undefined;
};
// reset connection state and websocket client on error
socket.onerror = (error) => {
console.error('WebSocket error:', error);
connectionState = ConnectionState.disconnected;
ws = undefined;
};
}
// reset connection state and websocket client on tab closed
$effect(() => {
return () => {
if (ws && ws.readyState === WebSocket.OPEN) {
connectionState = ConnectionState.disconnected;
ws.close();
}
};
});
// adds the template of the provided segment type at the bottom of the textbox
function addSegment(type: keyof typeof segmentTemplates) {
const template = segmentTemplates[type].template();
composedMessage += `\r\n${template}`;
}
// handles sending a message to the websocket server
function handleSendMessage(message: string) {
// check for active connection
if (!ws || ws.readyState !== WebSocket.OPEN || isSending || !stationId) {
console.log('Socket not ready');
return;
}
// set UI state
isSending = true;
deliveryError = '';
// construct Message object and send it
const messageToSend = {
type: 'send_hl7v2',
payload: { message },
} as Message;
ws.send(JSON.stringify(messageToSend));
}
// copies the stationId to the clipboard and handles UI state
function copyStationId() {
if (stationId) {
navigator.clipboard.writeText(stationId).then(() => {
copySuccess = true;
setTimeout(() => copySuccess = false, 2000);
});
}
}
</script>
<!-- 'component' for displaying a message in the message lists (sent and received) -->
{#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: Station 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 && stationId}
<Label class="">Station ID</Label>
<div class="flex items-center space-x-2">
<Input disabled bind:value={stationId}/>
<Button variant="outline" size="icon" onclick={copyStationId}>
{#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 && stationId}
<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>
</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>