chore: init

This commit is contained in:
2025-09-27 14:27:24 +02:00
commit 7679f138b6
157 changed files with 5197 additions and 0 deletions

View File

@@ -0,0 +1,92 @@
<script lang="ts">
import '../../app.css';
import favicon from '$lib/assets/favicon.svg';
import * as Sidebar from '$lib/components/ui/sidebar';
import AppSidebar from '$lib/AppSidebar.svelte';
import { Separator } from '$lib/components/ui/separator';
import * as Breadcrumb from '$lib/components/ui/breadcrumb';
import Spinner from '$lib/Spinner.svelte';
import { connectWS, wsState } from '$lib/ws.svelte';
import { Toaster } from '$lib/components/ui/sonner';
import ConnectionLostDialog from '$lib/ConnectionLostDialog.svelte';
import { onMount } from 'svelte';
import { browser } from '$app/environment';
let { children } = $props();
onMount(() => {
if(!browser) return;
connectWS();
console.log("Connecting to WebSocket...");
})
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
{#if wsState.closed}
<ConnectionLostDialog />
{/if}
{#if !wsState.connected && !wsState.closed}
<div id="blocker">
<Spinner />
<h2 class="text-2xl">Verbindung herstellen...</h2>
</div>
{/if}
<Toaster position="bottom-right" theme="dark" richColors={true} />
<Sidebar.Provider>
<AppSidebar />
<Sidebar.Inset>
<!-- <header class="flex h-16 shrink-0 items-center gap-2 px-4"> -->
<header class="flex h-2 shrink-0 items-center gap-2 px-4">
<!-- <Sidebar.Trigger class="-ml-1" /> -->
<!-- <Separator orientation="vertical" class="mr-2 data-[orientation=vertical]:h-4" /> -->
<!-- <Breadcrumb.Root>
<Breadcrumb.List>
<Breadcrumb.Item class="hidden md:block">
<Breadcrumb.Link href="#">Building Your Application</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator class="hidden md:block" />
<Breadcrumb.Item>
<Breadcrumb.Page>Data Fetching</Breadcrumb.Page>
</Breadcrumb.Item>
</Breadcrumb.List>
</Breadcrumb.Root> -->
</header>
<div class="flex flex-col gap-2">
{@render children?.()}
</div>
<!-- <div class="flex flex-1 flex-col gap-4 p-4">
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
<div class="bg-muted/50 aspect-video rounded-xl"></div>
<div class="bg-muted/50 aspect-video rounded-xl"></div>
<div class="bg-muted/50 aspect-video rounded-xl"></div>
</div>
<div class="bg-muted/50 min-h-[100vh] flex-1 rounded-xl md:min-h-min"></div>
</div> -->
</Sidebar.Inset>
</Sidebar.Provider>
<style>
#blocker {
position: fixed;
z-index: 9999;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(5px);
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 1rem;
}
</style>

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { eventTarget } from "$lib/ws.svelte";
import { onMount } from "svelte";
let noRoom = $state(false);
onMount(() => {
// ws.addEventListener("message", (e) => {
// const msg = JSON.parse(e.data);
// if(msg.type === "my-room") {
// goto(`/room/${msg.room}`);
// }
// });
// ws.addEventListener("open", () => {
// ws.send(JSON.stringify({ type: "my-room" }));
// });
eventTarget.addEventListener("my-room", (e) => {
console.log("Received my-room event", e);
const ev = e as CustomEvent;
// if(ev.detail.room == "entry") {
// goto(`/entry`);
// return;
// }
goto(`/room/${ev.detail.room}`);
});
eventTarget.addEventListener("error", (e) => {
const ev = e as CustomEvent;
if(ev.detail.code === -1) {
noRoom = true;
}
});
eventTarget.addEventListener("hello", (e) => {
eventTarget.dispatchEvent(new CustomEvent("send", { detail: { type: "my-room" } }));
});
})
</script>
<h1>Willkommen zu NextUp!</h1>
<span>Sie werden zu Ihrem Raum weitergeleitet.</span>
{#if noRoom}
<span class="text-red-500">Kein Raum verfügbar</span>
{/if}

View File

@@ -0,0 +1 @@
ADMIN

View File

@@ -0,0 +1,120 @@
<!-- <script>
import Button from "$lib/components/ui/button/button.svelte";
let newTicket = $state("---");
</script>
<h1 class="text-2xl font-bold">Empfang</h1>
<Button onclick={() => {
newTicket = Math.floor(100 + Math.random() * 900) + "";
}}>Neues Ticket</Button>
<div class="flex flex-col items-center mt-4 gap-2">
<span>Neues Ticket:</span>
<span id="new-ticket">{newTicket}</span>
<Button variant="secondary" onclick={() => {
newTicket = "---";
}}>Leeren</Button>
</div>
<style>
#new-ticket {
font-size: 2rem;
font-family: monospace;
padding: 0.5rem 1rem;
border: 2px solid var(--border);
border-radius: 0.5rem;
background: var(--background);
box-shadow: var(--shadow);
user-select: none;
text-align: center;
}
</style> -->
<!-- <script lang="ts">
import { page } from "$app/state";
import Button from "$lib/components/ui/button/button.svelte";
import * as InputOTP from "$lib/components/ui/input-otp";
import { eventTarget } from "$lib/ws.svelte";
import { onMount } from "svelte";
import { toast } from "svelte-sonner";
const slug = $derived(page.params.slug);
let nextTicket = $state("");
let isInvalid = $state(false);
const TICKET_INPUT_REGEX = "^[A-Z]{0,1}[0-9]{0,2}$";
const TICKET_REGEX = /^[A-Z]\d{2}$/;
onMount(() => {
eventTarget.addEventListener("error", (e) => {
const detail = (e as CustomEvent).detail;
if(detail.code === -2) {
toast.error(`Ticket ${detail.num} existiert bereits und wartet / ist aufgerufen`);
isInvalid = true;
}
});
eventTarget.addEventListener("create-ticket", (e) => {
const detail = (e as CustomEvent).detail;
if(detail.status === "created") {
toast.success(`Ticket ${detail.num} eingetragen`);
nextTicket = "";
}
});
eventTarget.addEventListener("complete-ticket", (e) => {
const detail = (e as CustomEvent).detail;
if(detail.status === "skipped") {
toast.success(`Ticket ${detail.num} übersprungen`);
} else if(detail.status === "completed") {
toast.success(`Ticket ${detail.num} abgeschlossen`);
} else if(detail.status === "no-show") {
toast.success(`Ticket ${detail.num} nicht erschienen`);
}
nextTicket = "";
});
})
function submit() {
if(!TICKET_REGEX.test(nextTicket)) {
isInvalid = true;
return;
}
eventTarget.dispatchEvent(new CustomEvent("send", { detail: { type: "create-ticket", ticket: nextTicket } }));
}
function remove() {
if(!TICKET_REGEX.test(nextTicket)) {
isInvalid = true;
return;
}
eventTarget.dispatchEvent(new CustomEvent("send", { detail: { type: "complete-ticket", ticket: nextTicket } }));
}
</script>
<h1 class="text-2xl font-bold">Empfang</h1>
<div style="display: flex; align-items: center; gap: 1rem;">
<InputOTP.Root maxlength={3} pattern={TICKET_INPUT_REGEX} bind:value={nextTicket} onkeypress={(e) => {
isInvalid = false;
if (e.key === "Enter") {
submit();
}
}}>
{#snippet children({ cells })}
<InputOTP.Group>
{#each cells.slice(0, 3) as cell}
<InputOTP.Slot {cell} aria-invalid={isInvalid} />
{/each}
</InputOTP.Group>
{/snippet}
</InputOTP.Root>
<Button onclick={() => {
submit();
}}>Eintragen</Button>
<Button variant="destructive" onclick={() => {
remove();
}}>Entfernen</Button>
</div> -->

View File

@@ -0,0 +1,68 @@
<script lang="ts">
import * as Table from "$lib/components/ui/table";
import { eventTarget } from "$lib/ws.svelte";
import { TicketCheckIcon, TicketIcon, TicketSlashIcon, TicketXIcon } from "@lucide/svelte";
import { onMount } from "svelte";
type LogAction = "called" | "completed" | "no-show";
const colors = {
called: "var(--chart-1)",
completed: "var(--chart-2)",
"no-show": "var(--destructive)",
}
const text = {
called: "Aufgerufen",
completed: "Erledigt",
"no-show": "Nicht erschienen",
}
const icons = {
called: TicketSlashIcon,
completed: TicketCheckIcon,
"no-show": TicketXIcon,
}
interface LogEntry {
ticket: string;
time: string;
action: LogAction;
room: string | null;
};
let logs = $state<LogEntry[]>([]);
onMount(() => {
eventTarget.addEventListener("log", (e) => {
const detail = (e as CustomEvent).detail;
logs = detail.log;
})
eventTarget.dispatchEvent(new CustomEvent("send", { detail: { type: "get-log" } }));
})
</script>
<h1 class="text-2xl font-bold">Log</h1>
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Ticketnr.</Table.Head>
<Table.Head>Zeit</Table.Head>
<Table.Head>Aktion</Table.Head>
<Table.Head>Raum</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each logs as log}
<Table.Row>
<Table.Cell class="font-medium">{log.ticket}</Table.Cell>
<Table.Cell>{new Date(log.time).toLocaleString("de-de")}</Table.Cell>
<Table.Cell style="display: flex; align-items: center; gap: 0.25rem; color: {colors[log.action]};">
{@const Icon = icons[log.action]}
<Icon />
{text[log.action]}
</Table.Cell>
<Table.Cell>{log.room}</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>

View File

@@ -0,0 +1,135 @@
<script lang="ts">
import { page } from "$app/state";
import Button from "$lib/components/ui/button/button.svelte";
import * as InputOTP from "$lib/components/ui/input-otp";
import { eventTarget } from "$lib/ws.svelte";
import { toast } from "svelte-sonner";
const slug = $derived(page.params.slug);
let nextTicket = $state("");
let currentTicket = $state("");
let isInvalid = $state(false);
const TICKET_INPUT_REGEX = "^[A-B]{0,1}[0-9]{0,2}$";
const TICKET_REGEX = /^[A-B]\d{2}$/;
async function submit() {
if(!TICKET_REGEX.test(nextTicket)) {
isInvalid = true;
return;
}
if(currentTicket !== "") {
eventTarget.dispatchEvent(new CustomEvent("send", { detail: { type: "complete-ticket", ticket: currentTicket } }));
await new Promise(r => {
eventTarget.addEventListener("complete-ticket", (e) => {
const detail = (e as CustomEvent).detail;
if(detail.status === "completed" || detail.status === "no-show") {
r(true);
}
}, { once: true });
})
}
eventTarget.dispatchEvent(new CustomEvent("send", { detail: { type: "call-ticket", ticket: nextTicket, room: slug } }));
}
async function stopCurrent() {
if(!currentTicket) return;
eventTarget.dispatchEvent(new CustomEvent("send", { detail: { type: "complete-ticket", ticket: currentTicket } }));
}
async function noShow() {
if(!currentTicket) return;
eventTarget.dispatchEvent(new CustomEvent("send", { detail: { type: "no-show", ticket: currentTicket } }));
}
$effect(() => {
eventTarget.addEventListener("error", (e) => {
const detail = (e as CustomEvent).detail;
if(detail.code === -3) {
toast.error(`Ticket ${detail.num} existiert nicht oder ist bereits abgeschlossen / nicht erschienen`);
isInvalid = true;
}
});
eventTarget.addEventListener("current-ticket", (e) => {
const detail = (e as CustomEvent).detail;
if(detail.room === slug) {
currentTicket = detail.num ?? "";
}
})
eventTarget.addEventListener("call-ticket", (e) => {
const detail = (e as CustomEvent).detail;
if(detail.status === "called" && detail.room === slug) {
currentTicket = detail.num;
nextTicket = "";
}
})
eventTarget.addEventListener("complete-ticket", (e) => {
const detail = (e as CustomEvent).detail;
if(detail.status === "completed" || detail.status === "no-show") {
currentTicket = "";
}
if(detail.status === "completed") {
toast.success(`Ticket ${detail.num} abgeschlossen`);
} else if(detail.status === "no-show") {
toast.success(`Ticket ${detail.num} nicht erschienen`);
}
})
eventTarget.dispatchEvent(new CustomEvent("send", { detail: { type: "get-current-ticket", room: slug } }));
})
</script>
<h1 class="text-2xl font-bold">Raum {slug}</h1>
<div style="display: flex; align-items: center; gap: 1rem;">
<InputOTP.Root maxlength={3} pattern={TICKET_INPUT_REGEX} bind:value={nextTicket} onkeypress={(e) => {
isInvalid = false;
if (e.key === "Enter") {
submit();
}
}}>
{#snippet children({ cells })}
<InputOTP.Group>
{#each cells.slice(0, 3) as cell}
<InputOTP.Slot {cell} aria-invalid={isInvalid} />
{/each}
</InputOTP.Group>
{/snippet}
</InputOTP.Root>
<Button onclick={() => {
submit();
}}>Aufrufen</Button>
<Button variant="destructive" onclick={() => {
noShow();
}}>Nicht erschienen</Button>
<Button variant="secondary" onclick={() => {
stopCurrent();
}}>Erledigen</Button>
</div>
{#if currentTicket !== ""}
<div class="flex flex-col items-center mt-4 gap-2">
<span>Jetzt Aufgerufen:</span>
<span id="new-ticket">{currentTicket}</span>
</div>
{/if}
<style>
#new-ticket {
font-size: 2rem;
font-family: monospace;
padding: 0.5rem 1rem;
border: 2px solid var(--border);
border-radius: 0.5rem;
background: var(--background);
box-shadow: var(--shadow);
user-select: none;
text-align: center;
}
#ticket {
display: flex;
}
</style>

View File

@@ -0,0 +1,100 @@
<script lang="ts">
import { page } from "$app/state";
import Badge from "$lib/components/ui/badge/badge.svelte";
import Button from "$lib/components/ui/button/button.svelte";
import { onMount } from "svelte";
const ticket = $derived(page.params.ticket);
type TicketStatus = "waiting" | "called" | "skipped" | "done";
const ticketInfo = $state({
created: new Date(),
status: "called" as TicketStatus,
room: "A" as string | undefined,
calledDate: new Date() as Date | undefined,
})
ticketInfo.created.setHours(ticketInfo.created.getHours() - Math.floor(Math.random() * 48));
onMount(() => {
setInterval(() => {
ticketInfo.status = (["waiting", "called", "skipped", "done"] as TicketStatus[])[Math.floor(Math.random() * 4)];
if(ticketInfo.status === "called") {
ticketInfo.calledDate = new Date();
ticketInfo.calledDate.setMinutes(ticketInfo.calledDate.getMinutes() - 10);
} else if(ticketInfo.status === "waiting") {
ticketInfo.calledDate = undefined;
}
if(ticketInfo.status === "called" || ticketInfo.status === "skipped" || ticketInfo.status === "done") {
ticketInfo.room = ["A", "B"][Math.floor(Math.random() * 2)];
} else {
ticketInfo.room = undefined;
}
}, 3000);
})
const rtf = new Intl.RelativeTimeFormat("de", { numeric: "auto" });
function timeAgo(date: Date) {
const now = new Date();
const diff = (date.getTime() - now.getTime()) / 1000; // in seconds
const units: [Intl.RelativeTimeFormatUnit, number][] = [
["year", 60 * 60 * 24 * 365],
["month", 60 * 60 * 24 * 30],
["week", 60 * 60 * 24 * 7],
["day", 60 * 60 * 24],
["hour", 60 * 60],
["minute", 60],
["second", 1],
];
for (const [unit, secondsInUnit] of units) {
if (Math.abs(diff) >= secondsInUnit || unit === "second") {
return rtf.format(Math.round(diff / secondsInUnit), unit);
}
}
}
function ticketStatus(status: TicketStatus) {
switch (status) {
case "waiting": return "Wartet";
case "called": return "Aufgerufen";
case "skipped": return "Übersprungen";
case "done": return "Erledigt";
}
}
function ticketStatusColor(status: TicketStatus) {
switch (status) {
case "waiting": return "secondary";
case "called": return "default";
case "skipped": return "destructive";
case "done": return "outline";
}
}
</script>
<h1 class="text-2xl font-bold flex items-center gap-2">Ticket {ticket} <Badge variant={ticketStatusColor(ticketInfo.status)}>{ticketStatus(ticketInfo.status)}</Badge></h1>
<span>Geöffnet <span class="bg-accent p-2 rounded-md" title={Intl.DateTimeFormat("de", { dateStyle: "medium", timeStyle: "short" }).format(ticketInfo.created)}>{timeAgo(ticketInfo.created)}</span></span>
{#if ticketInfo.room}
<span>Aufgerufen in: <span class="bg-accent p-2 rounded-md">{ticketInfo.room}</span></span>
{/if}
{#if ticketInfo.calledDate}
<span>Aufgerufen <span class="bg-accent p-2 rounded-md" title={Intl.DateTimeFormat("de", { dateStyle: "medium", timeStyle: "short" }).format(ticketInfo.calledDate)}>{timeAgo(ticketInfo.calledDate)}</span></span>
{/if}
{#if ticketInfo.status != "done"}
<Button variant="destructive" onclick={() => {
ticketInfo.status = "done";
}}>Ticket schließen</Button>
{:else}
<Button variant="secondary" onclick={() => {
ticketInfo.status = "waiting";
ticketInfo.calledDate = undefined;
ticketInfo.room = undefined;
ticketInfo.created = new Date();
}}>Ticket wieder öffnen</Button>
{/if}