319 lines
13 KiB
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>
|