Initial commit

This commit is contained in:
Markus Thielker 2025-07-24 15:08:39 +02:00
commit d9cff2e70c
72 changed files with 2878 additions and 0 deletions

View 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()}

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