Feat/channels (#94)

* Added channels dashboard ui

* Fix lint

* Fix tests

---------

Co-authored-by: Karl Ludwig Weise <ludwig@ludwigweise.de>
This commit is contained in:
Karl Ludwig Weise
2025-10-06 13:40:24 +02:00
committed by GitHub
co-authored by Karl Ludwig Weise
parent 86b2b0f869
commit c292538dc2
47 changed files with 2251 additions and 54 deletions
+14 -22
View File
@@ -33,7 +33,7 @@
"@eslint/js": "^9.29.0",
"@inlang/paraglide-js": "2.3.2",
"@internationalized/date": "^3.8.2",
"@lucide/svelte": "^0.542.0",
"@lucide/svelte": "^0.544.0",
"@playwright/test": "^1.53.1",
"@sveltejs/adapter-auto": "^6.1.0",
"@sveltejs/kit": "^2.22.0",
@@ -62,7 +62,7 @@
"svelte-sonner": "^1.0.5",
"sveltekit-superforms": "^2.27.1",
"tailwind-merge": "^3.3.1",
"tailwind-variants": "^1.0.0",
"tailwind-variants": "^3.1.1",
"tailwindcss": "^4.1.10",
"tw-animate-css": "^1.3.4",
"typescript": "^5.8.3",
@@ -1741,9 +1741,9 @@
"license": "Apache-2.0"
},
"node_modules/@lucide/svelte": {
"version": "0.542.0",
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-0.542.0.tgz",
"integrity": "sha512-NuWttxTVfMSURpOxcKiKvoCtma3JtEpcJWzF/0cO69saZfXlv6G8NYAvEEGLmk75YPl+I+ROe+F97WhddM8r2A==",
"version": "0.544.0",
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-0.544.0.tgz",
"integrity": "sha512-9f9O6uxng2pLB01sxNySHduJN3HTl5p0HDu4H26VR51vhZfiMzyOMe9Mhof3XAk4l813eTtl+/DYRvGyoRR+yw==",
"dev": true,
"license": "ISC",
"peerDependencies": {
@@ -7342,31 +7342,23 @@
}
},
"node_modules/tailwind-variants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-1.0.0.tgz",
"integrity": "sha512-2WSbv4ulEEyuBKomOunut65D8UZwxrHoRfYnxGcQNnHqlSCp2+B7Yz2W+yrNDrxRodOXtGD/1oCcKGNBnUqMqA==",
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.1.1.tgz",
"integrity": "sha512-ftLXe3krnqkMHsuBTEmaVUXYovXtPyTK7ckEfDRXS8PBZx0bAUas+A0jYxuKA5b8qg++wvQ3d2MQ7l/xeZxbZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tailwind-merge": "3.0.2"
},
"engines": {
"node": ">=16.x",
"pnpm": ">=7.x"
},
"peerDependencies": {
"tailwind-merge": ">=3.0.0",
"tailwindcss": "*"
}
},
"node_modules/tailwind-variants/node_modules/tailwind-merge": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.0.2.tgz",
"integrity": "sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw==",
"dev": true,
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
},
"peerDependenciesMeta": {
"tailwind-merge": {
"optional": true
}
}
},
"node_modules/tailwindcss": {
+2 -2
View File
@@ -42,7 +42,7 @@
"@eslint/js": "^9.29.0",
"@inlang/paraglide-js": "2.3.2",
"@internationalized/date": "^3.8.2",
"@lucide/svelte": "^0.542.0",
"@lucide/svelte": "^0.544.0",
"@playwright/test": "^1.53.1",
"@sveltejs/adapter-auto": "^6.1.0",
"@sveltejs/kit": "^2.22.0",
@@ -71,7 +71,7 @@
"svelte-sonner": "^1.0.5",
"sveltekit-superforms": "^2.27.1",
"tailwind-merge": "^3.3.1",
"tailwind-variants": "^1.0.0",
"tailwind-variants": "^3.1.1",
"tailwindcss": "^4.1.10",
"tw-animate-css": "^1.3.4",
"typescript": "^5.8.3",
+192 -1
View File
@@ -196,11 +196,112 @@
"cancel": "Abbrechen",
"apply": "Übernehmen"
}
},
"slotTemplate": {
"empty_weekdays": "Wochentage auswählen",
"weekdays_long": {
"monday": "Montag",
"tuesday": "Dienstag",
"wednesday": "Mittwoch",
"thursday": "Donnerstag",
"friday": "Freitag",
"saturday": "Samstag",
"sunday": "Sonntag"
},
"weekdays_short": {
"monday": "Mo",
"tuesday": "Di",
"wednesday": "Mi",
"thursday": "Do",
"friday": "Fr",
"saturday": "Sa",
"sunday": "So"
},
"times": {
"0500": "5:00 Uhr",
"0515": "5:15 Uhr",
"0530": "5:30 Uhr",
"0545": "5:45 Uhr",
"0600": "6:00 Uhr",
"0615": "6:15 Uhr",
"0630": "6:30 Uhr",
"0645": "6:45 Uhr",
"0700": "7:00 Uhr",
"0715": "7:15 Uhr",
"0730": "7:30 Uhr",
"0745": "7:45 Uhr",
"0800": "8:00 Uhr",
"0815": "8:15 Uhr",
"0830": "8:30 Uhr",
"0845": "8:45 Uhr",
"0900": "9:00 Uhr",
"0915": "9:15 Uhr",
"0930": "9:30 Uhr",
"0945": "9:45 Uhr",
"1000": "10:00 Uhr",
"1015": "10:15 Uhr",
"1030": "10:30 Uhr",
"1045": "10:45 Uhr",
"1100": "11:00 Uhr",
"1115": "11:15 Uhr",
"1130": "11:30 Uhr",
"1145": "11:45 Uhr",
"1200": "12:00 Uhr",
"1215": "12:15 Uhr",
"1230": "12:30 Uhr",
"1245": "12:45 Uhr",
"1300": "13:00 Uhr",
"1315": "13:15 Uhr",
"1330": "13:30 Uhr",
"1345": "13:45 Uhr",
"1400": "14:00 Uhr",
"1415": "14:15 Uhr",
"1430": "14:30 Uhr",
"1445": "14:45 Uhr",
"1500": "15:00 Uhr",
"1515": "15:15 Uhr",
"1530": "15:30 Uhr",
"1545": "15:45 Uhr",
"1600": "16:00 Uhr",
"1615": "16:15 Uhr",
"1630": "16:30 Uhr",
"1645": "16:45 Uhr",
"1700": "17:00 Uhr",
"1715": "17:15 Uhr",
"1730": "17:30 Uhr",
"1745": "17:45 Uhr",
"1800": "18:00 Uhr",
"1815": "18:15 Uhr",
"1830": "18:30 Uhr",
"1845": "18:45 Uhr",
"1900": "19:00 Uhr",
"1915": "19:15 Uhr",
"1930": "19:30 Uhr",
"1945": "19:45 Uhr",
"2000": "20:00 Uhr",
"2015": "20:15 Uhr",
"2030": "20:30 Uhr",
"2045": "20:45 Uhr",
"2100": "21:00 Uhr",
"2115": "21:15 Uhr",
"2130": "21:30 Uhr",
"2145": "21:45 Uhr",
"2200": "22:00 Uhr"
},
"empty_duration": "Dauer auswählen",
"template": "Vorlage {no}",
"remove": "Vorlage {no} entfernen",
"from": "Von",
"to": "Bis",
"duration": "Dauer",
"every_n_minutes": "alle {minutes} Minuten",
"add_template": "Vorlage hinzufügen",
"weekdays": "Wochentage"
}
},
"actions": "Aktionen",
"edit": "Editieren",
"pause": "Pausieren",
"pause": "Deaktivieren",
"delete": "Löschen",
"select": "Auswählen",
"close": "Schließen",
@@ -280,6 +381,96 @@
"en": "Englisch",
"de": "Deutsch"
},
"staff": {
"title": "Personal",
"empty": {
"title": "Bisher kein Personal",
"description": "Personal sind die Personen, die Zugriff auf Ihre Termine haben."
}
},
"channels": {
"title": "Kanäle",
"empty": {
"title": "Bisher keine Kanäle",
"description": "Kanäle sind Ihre Möglichkeit, verschiedene Arten von Terminen zu sammeln"
},
"add": {
"title": "Kanal hinzufügen",
"description": "Kanäle sind Ihre Möglichkeit, verschiedene Arten von Terminen zu sammeln",
"fields": {
"name": {
"title": "Name",
"errors": {
"length": "Muss zwischen 1 und 100 Zeichen lang sein"
}
},
"description": {
"title": "Beschreibung (optional)"
},
"agents": {
"title": "Akteure",
"description": "Wähle die Akteure aus, die Termine in diesem Kanal entgegennehmen können.",
"placeholder": "Akteure auswählen"
},
"isPublic": {
"title": "Öffentlich",
"label": "Dieser Kanal kann von Kunden direkt gebucht werden"
},
"requiresConfirmation": {
"title": "Bestätigung erforderlich",
"label": "Termine müssen vom Personal bestätigt werden"
},
"slotTemplates": {
"title": "Zeitvorlagen",
"description": "Termine werden basierend auf Ihrem Zeitplan erstellt."
}
},
"action": "Kanal hinzufügen",
"success": "Kanal hinzugefügt",
"errors": {
"unknown": "Konnte Kanal nicht hinzufügen"
}
},
"list": {
"empty": {
"title": "Bisher keine Kanäle",
"description": "Kanäle sind Ihre Möglichkeit, verschiedene Arten von Terminen zu sammeln"
},
"badges": {
"paused": "deaktiviert"
}
},
"delete": {
"title": "Kanal löschen",
"description": "Evtl. willst Du diesen Kanal deaktivieren, statt ihn zu löschen. Tippe {name} um den Kanal zu löschen.",
"action": "Kanal löschen",
"success": "Kanal gelöscht",
"error": "Konnte Kanal nicht löschen."
},
"loading": "Lade Kanäle",
"edit": {
"title": "Kanal bearbeiten",
"description": "Aktualisiere die öffentlichen Informationen des Kanals.",
"success": "Kanal aktualisiert",
"error": "Konnte Kanal nicht aktualisieren.",
"action": "Übernehmen"
},
"pause": {
"title": "Kanal deaktivieren",
"description": "Das Deaktivieren von {name} verhindert, dass Kunden neue Termine in diesem Kanal buchen.",
"action": "Kanal deaktivieren",
"success": "Kanal deaktiviert",
"error": "Konnte Kanal nicht deaktivieren."
},
"unpause": {
"title": "Kanal reaktivieren",
"description": "Das Reaktivieren von {name} ermöglicht es Kunden, neue Termine in diesem Kanal zu buchen.",
"action": "Kanal reaktivieren",
"success": "Kanal reaktiviert",
"error": "Konnte Kanal nicht reaktivieren."
}
},
"unpause": "Aktivieren",
"errorPages": {
"title": "Da ist was schiefgelaufen",
"descriptions": {
+192 -1
View File
@@ -204,12 +204,113 @@
"cancel": "Cancel",
"apply": "Apply"
}
},
"slotTemplate": {
"empty_weekdays": "Select weekdays",
"weekdays_long": {
"monday": "Monday",
"tuesday": "Tuesday",
"wednesday": "Wednesday",
"thursday": "Thursday",
"friday": "Friday",
"saturday": "Saturday",
"sunday": "Sunday"
},
"weekdays_short": {
"monday": "Mon",
"tuesday": "Tue",
"wednesday": "Wed",
"thursday": "Thu",
"friday": "Fri",
"saturday": "Sat",
"sunday": "Sun"
},
"times": {
"0500": "5:00 AM",
"0515": "5:15 AM",
"0530": "5:30 AM",
"0545": "5:45 AM",
"0600": "6:00 AM",
"0615": "6:15 AM",
"0630": "6:30 AM",
"0645": "6:45 AM",
"0700": "7:00 AM",
"0715": "7:15 AM",
"0730": "7:30 AM",
"0745": "7:45 AM",
"0800": "8:00 AM",
"0815": "8:15 AM",
"0830": "8:30 AM",
"0845": "8:45 AM",
"0900": "9:00 AM",
"0915": "9:15 AM",
"0930": "9:30 AM",
"0945": "9:45 AM",
"1000": "10:00 AM",
"1015": "10:15 AM",
"1030": "10:30 AM",
"1045": "10:45 AM",
"1100": "11:00 AM",
"1115": "11:15 AM",
"1130": "11:30 AM",
"1145": "11:45 AM",
"1200": "12:00 PM",
"1215": "12:15 PM",
"1230": "12:30 PM",
"1245": "12:45 PM",
"1300": "1:00 PM",
"1315": "1:15 PM",
"1330": "1:30 PM",
"1345": "1:45 PM",
"1400": "2:00 PM",
"1415": "2:15 PM",
"1430": "2:30 PM",
"1445": "2:45 PM",
"1500": "3:00 PM",
"1515": "3:15 PM",
"1530": "3:30 PM",
"1545": "3:45 PM",
"1600": "4:00 PM",
"1615": "4:15 PM",
"1630": "4:30 PM",
"1645": "4:45 PM",
"1700": "5:00 PM",
"1715": "5:15 PM",
"1730": "5:30 PM",
"1745": "5:45 PM",
"1800": "6:00 PM",
"1815": "6:15 PM",
"1830": "6:30 PM",
"1845": "6:45 PM",
"1900": "7:00 PM",
"1915": "7:15 PM",
"1930": "7:30 PM",
"1945": "7:45 PM",
"2000": "8:00 PM",
"2015": "8:15 PM",
"2030": "8:30 PM",
"2045": "8:45 PM",
"2100": "9:00 PM",
"2115": "9:15 PM",
"2130": "9:30 PM",
"2145": "9:45 PM",
"2200": "10:00 PM"
},
"empty_duration": "Select duration",
"template": "Template {no}",
"remove": "Remove teamplate {no}",
"from": "From",
"to": "To",
"duration": "Duration",
"every_n_minutes": "every {minutes} minutes",
"add_template": "Add Template",
"weekdays": "Weekdays"
}
},
"cancel": "Cancel",
"actions": "Actions",
"edit": "Edit",
"pause": "Pause",
"pause": "Deactivate",
"delete": "Delete",
"select": "Select",
"dashboard": {
@@ -289,6 +390,96 @@
"en": "English",
"de": "German"
},
"staff": {
"title": "Staff",
"empty": {
"title": "No staff members yet",
"description": "Staff members manage your appointments"
}
},
"channels": {
"title": "Channels",
"empty": {
"title": "No channels yet",
"description": "Channels are your way to collect different types of appointments"
},
"add": {
"title": "Add Channel",
"description": "Channels are your way to collect different types of appointments",
"fields": {
"name": {
"title": "Name",
"errors": {
"length": "Must be between 1 and 100 characters"
}
},
"description": {
"title": "Description (optional)"
},
"agents": {
"title": "Agents",
"description": "Add agents and we will manage the calendar and availability of your agents.",
"placeholder": "Select agents"
},
"isPublic": {
"title": "Public",
"label": "Anyone can book an appointment"
},
"requiresConfirmation": {
"title": "Requires Confirmation",
"label": "Appointments will have to be manually confirmed by staff"
},
"slotTemplates": {
"title": "Schedule",
"description": "Slots for appointments will be created based on your schedule."
}
},
"action": "Add Channel",
"success": "Channel added",
"errors": {
"unknown": "Could not add channel"
}
},
"list": {
"empty": {
"title": "No channels yet",
"description": "Channels are your way to collect different types of appointments"
},
"badges": {
"paused": "deactivated"
}
},
"delete": {
"title": "Delete Channel",
"description": "Consider deactivating the channel instead of deleting it. Type {name} to confirm.",
"action": "Delete Channel",
"success": "Channel deleted",
"error": "Could not delete channel."
},
"loading": "Loading channels",
"edit": {
"title": "Edit Channel",
"description": "Update the channels public details.",
"success": "Channel updated",
"error": "Could not update channel.",
"action": "Edit Channel"
},
"pause": {
"title": "Deactivate Channel",
"description": "Deactivating {name} will prevent clients from booking new appointments in this channel.",
"action": "Deactivate Channel",
"success": "Channel deactivated",
"error": "Could not deactivate channel."
},
"unpause": {
"title": "Activate Channel",
"description": "Activating {name} will allow clients to book new appointments in this channel.",
"action": "Activate Channel",
"success": "Channel activated",
"error": "Could not activate channel."
}
},
"unpause": "Activate",
"errorPages": {
"title": "Something went wrong",
"descriptions": {
@@ -1,5 +1,6 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { Badge, type BadgeVariant } from "$lib/components/ui/badge";
import { Button } from "$lib/components/ui/button/index.js";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
import { Text } from "$lib/components/ui/typography";
@@ -18,18 +19,25 @@
}
| { type: "divider" };
export type ListItemBadge = {
label: string;
variant?: BadgeVariant;
};
let {
image,
title,
description,
descriptionOnClick,
actions,
badges,
}: HTMLAttributes<HTMLLIElement> & {
image?: string | Component;
title: string;
description?: string;
descriptionOnClick?: () => void;
actions?: ListItemAction[];
badges?: ListItemBadge[];
} = $props();
let open = $state(false);
@@ -72,6 +80,13 @@
</Text>
{/if}
{/if}
{#if badges && badges.length > 0}
<div class="flex flex-wrap gap-1 py-1">
{#each badges as badge, index (`${badge.label}-${index}`)}
<Badge variant={badge.variant} class="uppercase">{badge.label}</Badge>
{/each}
</div>
{/if}
</div>
</div>
{#if actions && actions.length > 0}
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const badgeVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-md border px-2 py-0.5 text-xs font-medium transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
destructive:
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
});
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
</script>
<script lang="ts">
import type { HTMLAnchorAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
href,
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
variant?: BadgeVariant;
} = $props();
</script>
<svelte:element
this={href ? "a" : "span"}
bind:this={ref}
data-slot="badge"
{href}
class={cn(badgeVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</svelte:element>
+2
View File
@@ -0,0 +1,2 @@
export { default as Badge } from "./badge.svelte";
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
@@ -19,7 +19,13 @@
} = $props();
</script>
<label class={cn("flex items-center gap-2", className)}>
<Checkbox {...restProps} checked={value} value={value ? "true" : "false"} {onCheckedChange} />
<label class={cn("flex items-start gap-2", className)}>
<Checkbox
{...restProps}
checked={value}
value={value ? "true" : "false"}
{onCheckedChange}
class="mt-0.5"
/>
<Text style="sm" class="font-normal">{label}</Text>
</label>
@@ -14,7 +14,7 @@
<FormPrimitive.Description
bind:ref
data-slot="form-description"
class={cn("text-muted-foreground -mt-1 ml-1 text-sm", className)}
class={cn("text-muted-foreground -mt-1 text-sm", className)}
{...restProps}
>
<Text style="xs" color="medium" class="text-medium">
@@ -30,7 +30,7 @@
</Tabs.Content>
{/each}
</Tabs.Root>
<Separator class="mb-3" />
{:else if tenantLanguages.length === 1}
{@render children({ locale: tenantLanguages[0].key })}
{/if}
<Separator class="mb-3" />
@@ -36,6 +36,8 @@
import type { HTMLAttributes } from "svelte/elements";
import { MediaQuery } from "svelte/reactivity";
import { HorizontalPagePadding } from "../page";
import { ScrollArea } from "../scroll-area";
import { cn } from "$lib/utils";
let {
id,
@@ -98,16 +100,20 @@
{/if}
</Dialog.Trigger>
{/if}
<Dialog.Content class="sm:max-w-[425px]">
<Dialog.Content class="max-h-[95vh] sm:max-w-[425px]">
<Dialog.Header>
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Title class={cn(description ? "" : "-mb-1")}>{title}</Dialog.Title>
{#if description}
<Dialog.Description>
{description}
</Dialog.Description>
{/if}
</Dialog.Header>
{@render children?.()}
<ScrollArea class="-mx-1 max-h-[75vh]">
<div class="px-1 pt-2">
{@render children?.()}
</div>
</ScrollArea>
</Dialog.Content>
</Dialog.Root>
{:else}
@@ -121,16 +127,18 @@
{/if}
</Drawer.Trigger>
{/if}
<Drawer.Content>
<Drawer.Content
class="data-[vaul-drawer-direction=bottom]:max-h-[95vh] data-[vaul-drawer-direction=top]:max-h-[95vh]"
>
<Drawer.Header class="text-left">
<Drawer.Title>{title}</Drawer.Title>
<Drawer.Title class={cn(description ? "" : "-mb-1")}>{title}</Drawer.Title>
{#if description}
<Drawer.Description>
{description}
</Drawer.Description>
{/if}
</Drawer.Header>
<HorizontalPagePadding>
<HorizontalPagePadding class="max-h-[95vh] overflow-y-scroll pt-2">
{@render children?.()}
</HorizontalPagePadding>
<Drawer.Footer class="pt-2">
+37
View File
@@ -0,0 +1,37 @@
import { Select as SelectPrimitive } from "bits-ui";
import Group from "./select-group.svelte";
import Label from "./select-label.svelte";
import Item from "./select-item.svelte";
import Content from "./select-content.svelte";
import Trigger from "./select-trigger.svelte";
import Separator from "./select-separator.svelte";
import ScrollDownButton from "./select-scroll-down-button.svelte";
import ScrollUpButton from "./select-scroll-up-button.svelte";
import GroupHeading from "./select-group-heading.svelte";
const Root = SelectPrimitive.Root;
export {
Root,
Group,
Label,
Item,
Content,
Trigger,
Separator,
ScrollDownButton,
ScrollUpButton,
GroupHeading,
//
Root as Select,
Group as SelectGroup,
Label as SelectLabel,
Item as SelectItem,
Content as SelectContent,
Trigger as SelectTrigger,
Separator as SelectSeparator,
ScrollDownButton as SelectScrollDownButton,
ScrollUpButton as SelectScrollUpButton,
GroupHeading as SelectGroupHeading,
};
@@ -0,0 +1,40 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import SelectScrollUpButton from "./select-scroll-up-button.svelte";
import SelectScrollDownButton from "./select-scroll-down-button.svelte";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
portalProps,
children,
...restProps
}: WithoutChild<SelectPrimitive.ContentProps> & {
portalProps?: SelectPrimitive.PortalProps;
} = $props();
</script>
<SelectPrimitive.Portal {...portalProps}>
<SelectPrimitive.Content
bind:ref
{sideOffset}
data-slot="select-content"
class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--bits-select-content-available-height) min-w-[8rem] origin-(--bits-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
{...restProps}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
class={cn(
"h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1 p-1",
)}
>
{@render children?.()}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
@@ -0,0 +1,21 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
</script>
<SelectPrimitive.GroupHeading
bind:ref
data-slot="select-group-heading"
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...restProps}
>
{@render children?.()}
</SelectPrimitive.GroupHeading>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props();
</script>
<SelectPrimitive.Group data-slot="select-group" {...restProps} />
@@ -0,0 +1,38 @@
<script lang="ts">
import CheckIcon from "@lucide/svelte/icons/check";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
value,
label,
children: childrenProp,
...restProps
}: WithoutChild<SelectPrimitive.ItemProps> = $props();
</script>
<SelectPrimitive.Item
bind:ref
{value}
data-slot="select-item"
class={cn(
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...restProps}
>
{#snippet children({ selected, highlighted })}
<span class="absolute right-2 flex size-3.5 items-center justify-center">
{#if selected}
<CheckIcon class="size-4" />
{/if}
</span>
{#if childrenProp}
{@render childrenProp({ selected, highlighted })}
{:else}
{label || value}
{/if}
{/snippet}
</SelectPrimitive.Item>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
</script>
<div
bind:this={ref}
data-slot="select-label"
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
</script>
<SelectPrimitive.ScrollDownButton
bind:ref
data-slot="select-scroll-down-button"
class={cn("flex cursor-default items-center justify-center py-1", className)}
{...restProps}
>
<ChevronDownIcon class="size-4" />
</SelectPrimitive.ScrollDownButton>
@@ -0,0 +1,20 @@
<script lang="ts">
import ChevronUpIcon from "@lucide/svelte/icons/chevron-up";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
</script>
<SelectPrimitive.ScrollUpButton
bind:ref
data-slot="select-scroll-up-button"
class={cn("flex cursor-default items-center justify-center py-1", className)}
{...restProps}
>
<ChevronUpIcon class="size-4" />
</SelectPrimitive.ScrollUpButton>
@@ -0,0 +1,18 @@
<script lang="ts">
import type { Separator as SeparatorPrimitive } from "bits-ui";
import { Separator } from "$lib/components/ui/separator/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<Separator
bind:ref
data-slot="select-separator"
class={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...restProps}
/>
@@ -0,0 +1,29 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
size = "default",
...restProps
}: WithoutChild<SelectPrimitive.TriggerProps> & {
size?: "sm" | "default";
} = $props();
</script>
<SelectPrimitive.Trigger
bind:ref
data-slot="select-trigger"
data-size={size}
class={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none select-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...restProps}
>
{@render children?.()}
<ChevronDownIcon class="size-4 opacity-50" />
</SelectPrimitive.Trigger>
@@ -0,0 +1,3 @@
import SlotTemplate from "./slot-template.svelte";
export { SlotTemplate };
@@ -0,0 +1,195 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { Card } from "$lib/components/ui/card";
import * as Form from "$lib/components/ui/form";
import * as Select from "$lib/components/ui/select";
import type { TNewSlotTemplate, TSlotTemplate } from "$lib/types/channel";
import { cn } from "$lib/utils";
import { Trash } from "@lucide/svelte/icons";
import type { FsSuperForm } from "formsnap";
import type { HTMLAttributes } from "svelte/elements";
import { Button } from "../button";
import { Input } from "../input";
import { Text } from "../typography";
import { durations, times, weekdays } from "./utils";
let {
form,
index,
class: className,
value = $bindable(),
onRemove,
...restProps
}: HTMLAttributes<HTMLDivElement> & {
form: FsSuperForm<Record<string, unknown>>;
index: number;
value: (TSlotTemplate | TNewSlotTemplate) & { id?: string }; // Fixes type issue
onRemove: (index: number) => void;
} = $props();
const onWeekdaysChange = (values: string[]) => {
const newValue = values.map((v) => parseInt(v)).reduce((sum, x) => sum + x, 0);
value = { ...value, weekdays: newValue };
};
const toSelectedWeekdays = (bitmap: number | undefined) => {
if (!bitmap) return [];
return weekdays.filter(({ bit }) => (bitmap & bit) === bit).map(({ bit }) => `${bit}`);
};
const toWeekdaysLabel = (bitmap: number | undefined) => {
if (!bitmap) return m["components.slotTemplate.empty_weekdays"]();
switch (bitmap) {
case 0:
return m["components.slotTemplate.empty_weekdays"]();
case 127:
return `${weekdays[0].short}-${weekdays[6].short}`;
case 63:
return `${weekdays[0].short}-${weekdays[5].short}`;
case 31:
return `${weekdays[0].short}-${weekdays[4].short}`;
case 15:
return `${weekdays[0].short}-${weekdays[3].short}`;
case 7:
return `${weekdays[0].short}-${weekdays[2].short}`;
case 3:
return `${weekdays[0].short}-${weekdays[1].short}`;
default:
return weekdays
.filter(({ bit }) => (bitmap & bit) === bit)
.map(({ short }) => `${short}`)
.join(", ");
}
};
const getSelectedDuration = () => {
return (
durations[value.duration as keyof typeof durations]?.label ||
m["components.slotTemplate.empty_duration"]()
);
};
const getSelectedTime = (key: keyof typeof times) => {
return times[key]?.label || "";
};
</script>
<Card class={cn("gap-2 overflow-hidden p-3", className)} {...restProps}>
<div class="bg-muted -mx-3 -mt-3 mb-1 flex items-center justify-between px-3 py-2">
<Text style="sm">
{m["components.slotTemplate.template"]({ no: index + 1 })}
</Text>
<Button variant="ghost" size="icon" class="m-0 p-0" onclick={() => onRemove(index)}>
<Trash />
<span class="sr-only">
{m["components.slotTemplate.remove"]({ no: index + 1 })}
</span>
</Button>
</div>
<Form.Field {form} name="slotTemplates[{index}]id" class="hidden">
<Form.Control>
{#snippet children({ props })}
<Input {...props} value={value.id} type="hidden" />
{/snippet}
</Form.Control>
</Form.Field>
<Form.Field {form} name="slotTemplates[{index}]weekdays">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["components.slotTemplate.weekdays"]()}</Form.Label>
<Select.Root
type="multiple"
onValueChange={onWeekdaysChange}
name={props.name}
value={toSelectedWeekdays(value.weekdays)}
>
<Select.Trigger {...props} class="w-full">
{toWeekdaysLabel(value.weekdays)}
</Select.Trigger>
<Select.Content>
<Select.Item value={weekdays[0].bit.toString()}>{weekdays[0].day}</Select.Item>
<Select.Item value={weekdays[1].bit.toString()}>{weekdays[1].day}</Select.Item>
<Select.Item value={weekdays[2].bit.toString()}>{weekdays[2].day}</Select.Item>
<Select.Item value={weekdays[3].bit.toString()}>{weekdays[3].day}</Select.Item>
<Select.Item value={weekdays[4].bit.toString()}>{weekdays[4].day}</Select.Item>
<Select.Item value={weekdays[5].bit.toString()}>{weekdays[5].day}</Select.Item>
<Select.Item value={weekdays[6].bit.toString()}>{weekdays[6].day}</Select.Item>
</Select.Content>
</Select.Root>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<div class="flex gap-2">
<Form.Field {form} name="slotTemplates[{index}]from" class="flex-1">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["components.slotTemplate.from"]()}</Form.Label>
<Select.Root
type="single"
name={props.name}
value={value.from}
onValueChange={(v) => (value = { ...value, from: v })}
>
<Select.Trigger {...props} class="w-full">
{getSelectedTime(value.from as keyof typeof times)}
</Select.Trigger>
<Select.Content>
{#each Object.entries(times) as [time, value] (time)}
<Select.Item value={time}>{value.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="slotTemplates[{index}]to" class="flex-1">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["components.slotTemplate.to"]()}</Form.Label>
<Select.Root
type="single"
name={props.name}
value={value.to}
onValueChange={(v) => (value = { ...value, to: v })}
>
<Select.Trigger {...props} class="w-full">
{getSelectedTime(value.to as keyof typeof times)}
</Select.Trigger>
<Select.Content>
{#each Object.entries(times) as [time, value] (time)}
<Select.Item value={time}>{value.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
</div>
<Form.Field {form} name="slotTemplates[{index}]duration">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["components.slotTemplate.duration"]()}</Form.Label>
<Select.Root
type="single"
name={props.name}
value={value.duration.toString()}
onValueChange={(v) => (value = { ...value, duration: parseInt(v) })}
>
<Select.Trigger {...props} class="w-full">
{getSelectedDuration()}
</Select.Trigger>
<Select.Content>
{#each Object.entries(durations) as [duration, value] (duration)}
<Select.Item value={duration}>{value.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
</Card>
@@ -0,0 +1,124 @@
import { m } from "$i18n/messages";
export const weekdays = [
{
day: m["components.slotTemplate.weekdays_long.monday"](),
short: m["components.slotTemplate.weekdays_short.monday"](),
bit: 1,
},
{
day: m["components.slotTemplate.weekdays_long.tuesday"](),
short: m["components.slotTemplate.weekdays_short.tuesday"](),
bit: 2,
},
{
day: m["components.slotTemplate.weekdays_long.wednesday"](),
short: m["components.slotTemplate.weekdays_short.wednesday"](),
bit: 4,
},
{
day: m["components.slotTemplate.weekdays_long.thursday"](),
short: m["components.slotTemplate.weekdays_short.thursday"](),
bit: 8,
},
{
day: m["components.slotTemplate.weekdays_long.friday"](),
short: m["components.slotTemplate.weekdays_short.friday"](),
bit: 16,
},
{
day: m["components.slotTemplate.weekdays_long.saturday"](),
short: m["components.slotTemplate.weekdays_short.saturday"](),
bit: 32,
},
{
day: m["components.slotTemplate.weekdays_long.sunday"](),
short: m["components.slotTemplate.weekdays_short.sunday"](),
bit: 64,
},
];
export const durations = {
5: { label: m["components.slotTemplate.every_n_minutes"]({ minutes: 5 }) },
10: { label: m["components.slotTemplate.every_n_minutes"]({ minutes: 10 }) },
15: { label: m["components.slotTemplate.every_n_minutes"]({ minutes: 15 }) },
20: { label: m["components.slotTemplate.every_n_minutes"]({ minutes: 20 }) },
25: { label: m["components.slotTemplate.every_n_minutes"]({ minutes: 25 }) },
30: { label: m["components.slotTemplate.every_n_minutes"]({ minutes: 30 }) },
45: { label: m["components.slotTemplate.every_n_minutes"]({ minutes: 45 }) },
60: { label: m["components.slotTemplate.every_n_minutes"]({ minutes: 60 }) },
90: { label: m["components.slotTemplate.every_n_minutes"]({ minutes: 90 }) },
120: { label: m["components.slotTemplate.every_n_minutes"]({ minutes: 120 }) },
} as const;
export const times = {
"05:00:00": { label: m["components.slotTemplate.times.0500"]() },
"05:15:00": { label: m["components.slotTemplate.times.0515"]() },
"05:30:00": { label: m["components.slotTemplate.times.0530"]() },
"05:45:00": { label: m["components.slotTemplate.times.0545"]() },
"06:00:00": { label: m["components.slotTemplate.times.0600"]() },
"06:15:00": { label: m["components.slotTemplate.times.0615"]() },
"06:30:00": { label: m["components.slotTemplate.times.0630"]() },
"06:45:00": { label: m["components.slotTemplate.times.0645"]() },
"07:00:00": { label: m["components.slotTemplate.times.0700"]() },
"07:15:00": { label: m["components.slotTemplate.times.0715"]() },
"07:30:00": { label: m["components.slotTemplate.times.0730"]() },
"07:45:00": { label: m["components.slotTemplate.times.0745"]() },
"08:00:00": { label: m["components.slotTemplate.times.0800"]() },
"08:15:00": { label: m["components.slotTemplate.times.0815"]() },
"08:30:00": { label: m["components.slotTemplate.times.0830"]() },
"08:45:00": { label: m["components.slotTemplate.times.0845"]() },
"09:00:00": { label: m["components.slotTemplate.times.0900"]() },
"09:15:00": { label: m["components.slotTemplate.times.0915"]() },
"09:30:00": { label: m["components.slotTemplate.times.0930"]() },
"09:45:00": { label: m["components.slotTemplate.times.0945"]() },
"10:00:00": { label: m["components.slotTemplate.times.1000"]() },
"10:15:00": { label: m["components.slotTemplate.times.1015"]() },
"10:30:00": { label: m["components.slotTemplate.times.1030"]() },
"10:45:00": { label: m["components.slotTemplate.times.1045"]() },
"11:00:00": { label: m["components.slotTemplate.times.1100"]() },
"11:15:00": { label: m["components.slotTemplate.times.1115"]() },
"11:30:00": { label: m["components.slotTemplate.times.1130"]() },
"11:45:00": { label: m["components.slotTemplate.times.1145"]() },
"12:00:00": { label: m["components.slotTemplate.times.1200"]() },
"12:15:00": { label: m["components.slotTemplate.times.1215"]() },
"12:30:00": { label: m["components.slotTemplate.times.1230"]() },
"12:45:00": { label: m["components.slotTemplate.times.1245"]() },
"13:00:00": { label: m["components.slotTemplate.times.1300"]() },
"13:15:00": { label: m["components.slotTemplate.times.1315"]() },
"13:30:00": { label: m["components.slotTemplate.times.1330"]() },
"13:45:00": { label: m["components.slotTemplate.times.1345"]() },
"14:00:00": { label: m["components.slotTemplate.times.1400"]() },
"14:15:00": { label: m["components.slotTemplate.times.1415"]() },
"14:30:00": { label: m["components.slotTemplate.times.1430"]() },
"14:45:00": { label: m["components.slotTemplate.times.1445"]() },
"15:00:00": { label: m["components.slotTemplate.times.1500"]() },
"15:15:00": { label: m["components.slotTemplate.times.1515"]() },
"15:30:00": { label: m["components.slotTemplate.times.1530"]() },
"15:45:00": { label: m["components.slotTemplate.times.1545"]() },
"16:00:00": { label: m["components.slotTemplate.times.1600"]() },
"16:15:00": { label: m["components.slotTemplate.times.1615"]() },
"16:30:00": { label: m["components.slotTemplate.times.1630"]() },
"16:45:00": { label: m["components.slotTemplate.times.1645"]() },
"17:00:00": { label: m["components.slotTemplate.times.1700"]() },
"17:15:00": { label: m["components.slotTemplate.times.1715"]() },
"17:30:00": { label: m["components.slotTemplate.times.1730"]() },
"17:45:00": { label: m["components.slotTemplate.times.1745"]() },
"18:00:00": { label: m["components.slotTemplate.times.1800"]() },
"18:15:00": { label: m["components.slotTemplate.times.1815"]() },
"18:30:00": { label: m["components.slotTemplate.times.1830"]() },
"18:45:00": { label: m["components.slotTemplate.times.1845"]() },
"19:00:00": { label: m["components.slotTemplate.times.1900"]() },
"19:15:00": { label: m["components.slotTemplate.times.1915"]() },
"19:30:00": { label: m["components.slotTemplate.times.1930"]() },
"19:45:00": { label: m["components.slotTemplate.times.1945"]() },
"20:00:00": { label: m["components.slotTemplate.times.2000"]() },
"20:15:00": { label: m["components.slotTemplate.times.2015"]() },
"20:30:00": { label: m["components.slotTemplate.times.2030"]() },
"20:45:00": { label: m["components.slotTemplate.times.2045"]() },
"21:00:00": { label: m["components.slotTemplate.times.2100"]() },
"21:15:00": { label: m["components.slotTemplate.times.2115"]() },
"21:30:00": { label: m["components.slotTemplate.times.2130"]() },
"21:45:00": { label: m["components.slotTemplate.times.2145"]() },
"22:00:00": { label: m["components.slotTemplate.times.2200"]() },
} as const;
@@ -141,8 +141,8 @@ describe("ChannelService", () => {
slotTemplates: [
{
name: "Test Slot",
from: "09:00",
to: "17:00",
from: "09:00:00",
to: "17:00:00",
duration: 30,
},
],
@@ -172,7 +172,7 @@ describe("ChannelService", () => {
{
name: "Test Slot",
from: "invalid-time",
to: "17:00",
to: "17:00:00",
duration: 30,
},
],
+13 -12
View File
@@ -1,13 +1,12 @@
import { getTenantDb } from "../db";
import * as tenantSchema from "../db/tenant-schema";
import { type SelectChannel, type SelectSlotTemplate, type SelectAgent } from "../db/tenant-schema";
import { eq, inArray } from "drizzle-orm";
import logger from "$lib/logger";
import z from "zod/v4";
import { ValidationError, NotFoundError } from "../utils/errors";
import { TenantConfig } from "../db/tenant-config";
import { supportedLocales } from "$lib/const/locales";
import logger from "$lib/logger";
import { asc, eq, inArray, sql } from "drizzle-orm";
import z from "zod/v4";
import { getTenantDb } from "../db";
import { TenantConfig } from "../db/tenant-config";
import * as tenantSchema from "../db/tenant-schema";
import { type SelectAgent, type SelectChannel, type SelectSlotTemplate } from "../db/tenant-schema";
import { NotFoundError, ValidationError } from "../utils/errors";
const CHANNEL_COLORS = ["#FF0000", "#00FF00", "#0000FF"] as const;
const NEXT_COLOR_KEY = "nextChannelColor";
@@ -15,8 +14,8 @@ const NEXT_COLOR_KEY = "nextChannelColor";
const slotTemplateSchema = z.object({
name: z.string().min(1).max(100),
weekdays: z.number().int().min(0).max(127).optional(),
from: z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/),
to: z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/),
from: z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/),
to: z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/),
duration: z.number().int().min(1).max(1440),
});
@@ -552,10 +551,12 @@ export class ChannelService {
const db = await this.getDb();
// Get all channels
// TODO: What should be the locale used here? Ideally it should be the users locale
const language = supportedLocales[0];
const channels = await db
.select()
.from(tenantSchema.channel)
.orderBy(tenantSchema.channel.names);
.orderBy(asc(sql`${tenantSchema.channel.names}->>${language}`));
// Get relations for each channel
const result: ChannelWithRelations[] = [];
+52
View File
@@ -0,0 +1,52 @@
import { browser } from "$app/environment";
import type { TAgent } from "$lib/types/agent";
import { writable } from "svelte/store";
import { auth } from "./auth";
interface AgentsState {
agents: TAgent[];
isLoading: boolean;
}
const createAgentsStore = () => {
const store = writable<AgentsState>({
agents: [],
isLoading: false,
});
return {
...store,
load: async () => {
if (!browser) return;
store.update((state) => {
return { ...state, isLoading: true };
});
try {
const tenantId = auth.getTenant();
const res = await fetch(`/api/tenants/${tenantId}/agents`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
credentials: "same-origin",
});
const body = await res.json();
const agents = body.agents ?? ([] as TAgent[]);
store.update((state) => {
return { ...state, agents, isLoading: false };
});
} catch (error) {
store.update((state) => {
return { ...state, isLoading: false };
});
console.error("Failed to parse agents response", { error });
}
},
};
};
export const agents = createAgentsStore();
+8
View File
@@ -7,6 +7,7 @@ import { auth } from "./auth";
import { m } from "$i18n/messages";
import { goto } from "$app/navigation";
import { ROUTES } from "$lib/const/routes";
import { agents } from "./agents";
const log = logger.setContext("TenantsStore");
@@ -30,6 +31,8 @@ const createTenantsStore = () => {
},
setCurrentTenant: (tenantId: string | null) => {
const curTenant = auth.getTenant();
// Switch tenant on api level
if (tenantId !== curTenant) {
const success = changeTenantUsingApi(tenantId);
if (!success) {
@@ -38,11 +41,16 @@ const createTenantsStore = () => {
return;
}
}
// Update store
store.update((state) => {
const currentTenant = state.tenants.find((t) => t.id === tenantId) || null;
auth.setTenantId(tenantId);
return { ...state, currentTenant };
});
agents.load();
// Redirect to dashboard main if tenant changed to avaoid showing data from previous tenant
if (tenantId !== curTenant) {
goto(ROUTES.DASHBOARD.MAIN);
}
+18
View File
@@ -0,0 +1,18 @@
import type { SelectChannel } from "$lib/server/db/tenant-schema";
import type { SlotTemplateRequest } from "$lib/server/services/channel-service";
export type TNewSlotTemplate = SlotTemplateRequest;
export type TSlotTemplate = TNewSlotTemplate & { id: string };
export type TChannel = SelectChannel & {
agentIds: string[];
slotTemplates: (TSlotTemplate | TNewSlotTemplate)[];
};
export type TChannelWithFullAgents = Omit<TChannel, "agentIds"> & {
agents: {
id: string;
name: string;
// and more that we don't use in FE yet
}[];
};
+16
View File
@@ -0,0 +1,16 @@
import { getLocale } from "$i18n/runtime";
export const removeEmptyTranslations = (object: { [key: string]: string } | undefined) => {
if (!object) return object;
// Remove all keys, but only if every value is empty
const allEmpty = Object.values(object).every((value) => !value);
if (allEmpty) return {};
return object;
};
export const getCurrentTranlslation = (object: { [key: string]: string } | undefined) => {
if (!object) return "[No Translation]";
return object[getLocale()] || Object.values(object)[0];
};
@@ -7,6 +7,7 @@ import { formSchema as addFormSchema } from "./(components)/add-agent-form";
import { formSchema as editFormSchema } from "./(components)/edit-agent-form";
import { formSchema as deleteFormSchema } from "./(components)/delete-agent-form";
import type { TAgent } from "$lib/types/agent";
import { removeEmptyTranslations } from "$lib/utils/localizations";
const log = logger.setContext(import.meta.filename);
@@ -70,7 +71,7 @@ export const actions: Actions = {
credentials: "same-origin",
body: JSON.stringify({
name: form.data.name,
descriptions: form.data.descriptions,
descriptions: removeEmptyTranslations(form.data.descriptions),
image: form.data.image,
}),
});
@@ -117,7 +118,7 @@ export const actions: Actions = {
credentials: "same-origin",
body: JSON.stringify({
name: form.data.name,
descriptions: form.data.descriptions,
descriptions: removeEmptyTranslations(form.data.descriptions),
image: form.data.image ?? null,
}),
},
@@ -18,6 +18,7 @@
import { AddAgentForm } from "./(components)/add-agent-form";
import DeleteAgentForm from "./(components)/delete-agent-form/delete-agent-form.svelte";
import EditAgentForm from "./(components)/edit-agent-form/edit-agent-form.svelte";
import { agents } from "$lib/stores/agents";
const { data } = $props();
let curItem: TAgent | null = $state(null);
@@ -58,6 +59,7 @@
{/snippet}
<AddAgentForm
done={() => {
agents.load();
invalidate(ROUTES.DASHBOARD.AGENTS);
closeDialog("add");
}}
@@ -111,6 +113,7 @@
done={() => {
closeDialog("edit");
curItem = null;
agents.load();
invalidate(ROUTES.DASHBOARD.AGENTS);
}}
/>
@@ -123,6 +126,7 @@
done={() => {
closeDialog("delete");
curItem = null;
agents.load();
invalidate(ROUTES.DASHBOARD.AGENTS);
}}
/>
@@ -0,0 +1,187 @@
<script lang="ts">
import { m } from "$i18n/messages.js";
import { Button } from "$lib/components/ui/button";
import { CheckboxWithLabel } from "$lib/components/ui/checkbox-with-label";
import * as Form from "$lib/components/ui/form";
import { Input } from "$lib/components/ui/input";
import { LanguageTabs } from "$lib/components/ui/language-tabs";
import * as Select from "$lib/components/ui/select";
import { SlotTemplate } from "$lib/components/ui/slot-template";
import { Textarea } from "$lib/components/ui/textarea";
import { Headline, Text } from "$lib/components/ui/typography";
import { agents as agentsStore } from "$lib/stores/agents";
import { tenants } from "$lib/stores/tenants";
import { toast } from "svelte-sonner";
import { get } from "svelte/store";
import { superForm } from "sveltekit-superforms";
import { zodClient } from "sveltekit-superforms/adapters";
import { generateSlotTemplate } from "../utils";
import { formSchema } from "./schema";
let { done }: { done: () => void } = $props();
const agents = get(agentsStore).agents ?? [];
const tenantLocales = get(tenants).currentTenant?.languages ?? [];
const form = superForm(
{
names: tenantLocales.reduce(
(acc, locale) => ({ ...acc, [locale]: "" }),
{} as { [key: string]: string },
),
descriptions: tenantLocales.reduce(
(acc, locale) => ({ ...acc, [locale]: "" }),
{} as { [key: string]: string },
),
agentIds: [] as string[],
isPublic: true,
requiresConfirmation: false,
slotTemplates: [generateSlotTemplate(0)],
},
{
dataType: "json",
validators: zodClient(formSchema),
onResult: async (event) => {
if (event.result.type === "success") {
toast.success(m["channels.add.success"]());
done();
} else if (event.result.type === "failure") {
toast.error(m["channels.add.errors.unknown"]());
}
isSubmitting = false;
},
onSubmit: () => (isSubmitting = true),
},
);
let isSubmitting = $state(false);
const { form: formData, enhance } = form;
const onAddSlotTemplate = () => {
$formData.slotTemplates = [
...$formData.slotTemplates,
generateSlotTemplate($formData.slotTemplates.length),
];
};
const onRemoveSlotTemplate = (index: number) => {
$formData.slotTemplates = $formData.slotTemplates.filter((_, i) => i !== index);
};
</script>
<Form.Root {enhance} action="?/add">
<LanguageTabs>
{#snippet children({ locale })}
<Form.Field {form} name={`names.${locale}`}>
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["channels.add.fields.name.title"]()}</Form.Label>
<Input
{...props}
bind:value={$formData.names[locale]}
minlength={2}
maxlength={50}
autocomplete="off"
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name={`descriptions.${locale}`}>
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["channels.add.fields.description.title"]()}</Form.Label>
<Textarea {...props} bind:value={$formData.descriptions[locale]} maxlength={1000} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
{/snippet}
</LanguageTabs>
<Form.Field {form} name="agentIds">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["channels.add.fields.agents.title"]()}</Form.Label>
<Form.Description>
{m["channels.add.fields.agents.description"]()}
</Form.Description>
<Select.Root
type="multiple"
bind:value={$formData.agentIds}
name={props.name}
onValueChange={(v) => ($formData.agentIds = v)}
>
<Select.Trigger {...props} class="w-full">
{$formData.agentIds.length > 0
? $formData.agentIds.map((id) => agents.find((x) => x.id === id)?.name).join(", ")
: m["channels.add.fields.agents.placeholder"]()}
</Select.Trigger>
<Select.Content>
{#each agents as agent (agent.id)}
<Select.Item value={agent.id}>{agent.name}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="isPublic">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["channels.add.fields.isPublic.title"]()}</Form.Label>
<CheckboxWithLabel
{...props}
bind:value={$formData.isPublic}
label={m["channels.add.fields.isPublic.label"]()}
onCheckedChange={(v) => {
$formData.isPublic = v;
}}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="requiresConfirmation">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["channels.add.fields.requiresConfirmation.title"]()}</Form.Label>
<CheckboxWithLabel
{...props}
bind:value={$formData.requiresConfirmation}
label={m["channels.add.fields.requiresConfirmation.label"]()}
onCheckedChange={(v) => {
$formData.requiresConfirmation = v;
}}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="slotTemplates">
<Headline style="h4" level="h3" class="mb-0">
{m["channels.add.fields.slotTemplates.title"]()}
</Headline>
<Text style="sm" class="font-normal">
{m["channels.add.fields.slotTemplates.description"]()}
</Text>
<div class="flex flex-col gap-2 pt-2">
{#each $formData.slotTemplates as template, i (`${template.name}-${i}`)}
<SlotTemplate
index={i}
{form}
bind:value={$formData.slotTemplates[i]}
onRemove={onRemoveSlotTemplate}
/>
{/each}
<Button variant="outline" onclick={onAddSlotTemplate}>
{m["components.slotTemplate.add_template"]()}
</Button>
</div>
</Form.Field>
<div class="mt-6 flex flex-col gap-4">
<Form.Button size="lg" type="submit" isLoading={isSubmitting} disabled={isSubmitting}>
{m["channels.add.action"]()}
</Form.Button>
</div>
</Form.Root>
@@ -0,0 +1,5 @@
import AddChannelForm from "./add-channel-form.svelte";
export { AddChannelForm };
export { formSchema } from "./schema";
export type { FormSchema } from "./schema";
@@ -0,0 +1,32 @@
import { m } from "$i18n/messages";
import { z } from "zod";
export const formSchema = z.object({
names: z
.record(
z.string(),
z
.string()
.min(2, m["channels.add.fields.name.errors.length"]())
.max(50, m["channels.add.fields.name.errors.length"]()),
)
.optional(),
descriptions: z.record(z.string(), z.string()).optional(),
agentIds: z.array(z.string()).default([]),
isPublic: z.boolean().optional().default(false),
requiresConfirmation: z.boolean().optional().default(false),
slotTemplates: z
.array(
z.object({
name: z.string().min(1).max(100),
weekdays: z.number().int().min(0).max(127).default(15),
from: z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/),
to: z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/),
duration: z.number().int().min(1).max(1440),
}),
)
.optional()
.default([]),
});
export type FormSchema = typeof formSchema;
@@ -0,0 +1,85 @@
<script lang="ts">
import { m } from "$i18n/messages.js";
import * as Form from "$lib/components/ui/form";
import { InlineCode } from "$lib/components/ui/inline-code";
import { Input } from "$lib/components/ui/input";
import { TranslationWithComponent } from "$lib/components/ui/translation-with-component";
import { Text } from "$lib/components/ui/typography";
import { toast } from "svelte-sonner";
import { superForm } from "sveltekit-superforms";
import { zodClient } from "sveltekit-superforms/adapters";
import z from "zod";
import { formSchema } from ".";
import type { TChannelWithFullAgents } from "$lib/types/channel";
import { getCurrentTranlslation } from "$lib/utils/localizations";
let { entity, done }: { entity: TChannelWithFullAgents; done: () => void } = $props();
const form = superForm(
{ id: entity.id, name: "" },
{
validators: zodClient(
formSchema.merge(
z.object({
name: z.string().refine((val) => val === getCurrentTranlslation(entity.names), {
message: m["form.errors.deleteConfirmation"]({
expectedValue: getCurrentTranlslation(entity.names),
}),
}),
}),
),
),
onResult: async (event) => {
if (event.result.type === "success") {
toast.success(m["channels.delete.success"]());
done();
} else if (event.result.type === "failure") {
toast.error(m["channels.delete.error"]());
}
isSubmitting = false;
},
onSubmit: () => (isSubmitting = true),
},
);
let isSubmitting = $state(false);
const { form: formData, enhance } = form;
</script>
<Form.Root {enhance} action="?/delete">
<Text style="sm" class="text-muted-foreground -mt-2 font-normal">
<TranslationWithComponent
translation={m["channels.delete.description"]({ name: "{name}" })}
interpolations={[
{ param: "{name}", value: getCurrentTranlslation(entity.names), snippet: inlineCode },
]}
/>
</Text>
<Form.Field {form} name="name">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["form.name"]()}</Form.Label>
<Input {...props} bind:value={$formData.name} type="text" autocomplete="off" />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="id" class="hidden">
<Form.Control>
{#snippet children({ props })}
<Input {...props} bind:value={$formData.id} type="hidden" />
{/snippet}
</Form.Control>
</Form.Field>
<div class="mt-6 flex flex-col gap-4">
<Form.Button size="lg" type="submit" isLoading={isSubmitting} disabled={isSubmitting}>
{m["channels.delete.action"]()}
</Form.Button>
</div>
</Form.Root>
{#snippet inlineCode(value: string | number)}
<InlineCode>{value}</InlineCode>
{/snippet}
@@ -0,0 +1,5 @@
import DeleteChannelForm from "./delete-channel-form.svelte";
export { DeleteChannelForm };
export { formSchema } from "./schema";
export type { FormSchema } from "./schema";
@@ -0,0 +1,12 @@
import { m } from "$i18n/messages";
import { z } from "zod";
export const formSchema = z.object({
id: z.string(),
name: z
.string()
.min(1, m["channels.add.fields.name.errors.length"]())
.max(100, m["channels.add.fields.name.errors.length"]()),
});
export type FormSchema = typeof formSchema;
@@ -0,0 +1,203 @@
<script lang="ts">
import { m } from "$i18n/messages.js";
import { Button } from "$lib/components/ui/button";
import { CheckboxWithLabel } from "$lib/components/ui/checkbox-with-label";
import * as Form from "$lib/components/ui/form";
import { Input } from "$lib/components/ui/input";
import { LanguageTabs } from "$lib/components/ui/language-tabs";
import * as Select from "$lib/components/ui/select";
import { SlotTemplate } from "$lib/components/ui/slot-template";
import { Textarea } from "$lib/components/ui/textarea";
import { Headline, Text } from "$lib/components/ui/typography";
import { agents as agentsStore } from "$lib/stores/agents";
import { tenants } from "$lib/stores/tenants";
import type { TChannelWithFullAgents, TSlotTemplate } from "$lib/types/channel";
import { toast } from "svelte-sonner";
import { get } from "svelte/store";
import { superForm } from "sveltekit-superforms";
import { zodClient } from "sveltekit-superforms/adapters";
import { formSchema } from ".";
import { generateSlotTemplate } from "../utils";
let { entity, done }: { entity: TChannelWithFullAgents; done: () => void } = $props();
const agents = get(agentsStore).agents ?? [];
const tenantLocales = get(tenants).currentTenant?.languages ?? [];
const form = superForm(
{
id: entity.id,
names: tenantLocales.reduce(
(acc, locale) => ({ ...acc, [locale]: entity.names[locale] ?? "" }),
{} as { [key: string]: string },
),
descriptions: tenantLocales.reduce(
(acc, locale) => ({ ...acc, [locale]: entity.descriptions[locale] ?? "" }),
{} as { [key: string]: string },
),
agentIds: entity.agents.map((a) => a.id) ?? [],
isPublic: entity.isPublic || false,
requiresConfirmation: entity.requiresConfirmation || false,
slotTemplates: entity.slotTemplates,
},
{
dataType: "json",
validators: zodClient(formSchema),
onResult: async (event) => {
if (event.result.type === "success") {
toast.success(m["channels.edit.success"]());
done();
} else if (event.result.type === "failure") {
toast.error(m["channels.edit.error"]());
}
isSubmitting = false;
},
// onSubmit: () => (isSubmitting = true),
onSubmit: ({ formData }) => {
// TODO: Fix editing of channels with slot templates
console.log("formData", formData);
const validation = formSchema.safeParse(formData);
console.log("validation", validation);
isSubmitting = true;
},
},
);
let isSubmitting = $state(false);
const { form: formData, enhance } = form;
const onAddSlotTemplate = () => {
$formData.slotTemplates = [
...$formData.slotTemplates,
generateSlotTemplate($formData.slotTemplates.length),
] as TSlotTemplate[];
};
const onRemoveSlotTemplate = (index: number) => {
$formData.slotTemplates = $formData.slotTemplates.filter((_, i) => i !== index);
};
</script>
<Form.Root {enhance} action="?/edit">
<Form.Field {form} name="id" class="hidden">
<Form.Control>
{#snippet children({ props })}
<Input {...props} bind:value={$formData.id} type="hidden" />
{/snippet}
</Form.Control>
</Form.Field>
<LanguageTabs>
{#snippet children({ locale })}
<Form.Field {form} name={`names.${locale}`}>
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["channels.add.fields.name.title"]()}</Form.Label>
<Input
{...props}
bind:value={$formData.names[locale]}
minlength={2}
maxlength={50}
autocomplete="off"
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name={`descriptions.${locale}`}>
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["channels.add.fields.description.title"]()}</Form.Label>
<Textarea {...props} bind:value={$formData.descriptions[locale]} maxlength={1000} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
{/snippet}
</LanguageTabs>
<Form.Field {form} name="agentIds">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["channels.add.fields.agents.title"]()}</Form.Label>
<Form.Description>
{m["channels.add.fields.agents.description"]()}
</Form.Description>
<Select.Root
type="multiple"
bind:value={$formData.agentIds}
name={props.name}
onValueChange={(v) => ($formData.agentIds = v)}
>
<Select.Trigger {...props} class="w-full">
{$formData.agentIds.length > 0
? $formData.agentIds.map((id) => agents.find((x) => x.id === id)?.name).join(", ")
: m["channels.add.fields.agents.placeholder"]()}
</Select.Trigger>
<Select.Content>
{#each agents as agent (agent.id)}
<Select.Item value={agent.id}>{agent.name}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="isPublic">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["channels.add.fields.isPublic.title"]()}</Form.Label>
<CheckboxWithLabel
{...props}
bind:value={$formData.isPublic}
label={m["channels.add.fields.isPublic.label"]()}
onCheckedChange={(v) => {
$formData.isPublic = v;
}}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="requiresConfirmation">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["channels.add.fields.requiresConfirmation.title"]()}</Form.Label>
<CheckboxWithLabel
{...props}
bind:value={$formData.requiresConfirmation}
label={m["channels.add.fields.requiresConfirmation.label"]()}
onCheckedChange={(v) => {
$formData.requiresConfirmation = v;
}}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="slotTemplates">
<Headline style="h4" level="h3" class="mb-0">
{m["channels.add.fields.slotTemplates.title"]()}
</Headline>
<Text style="sm" class="font-normal">
{m["channels.add.fields.slotTemplates.description"]()}
</Text>
<div class="flex flex-col gap-2 pt-2">
{#each $formData.slotTemplates as template, i (`${template.name}-${i}`)}
<SlotTemplate
index={i}
{form}
bind:value={$formData.slotTemplates[i]}
onRemove={onRemoveSlotTemplate}
/>
{/each}
<Button variant="outline" onclick={onAddSlotTemplate}>
{m["components.slotTemplate.add_template"]()}
</Button>
</div>
</Form.Field>
<div class="mt-6 flex flex-col gap-4">
<Form.Button size="lg" type="submit" isLoading={isSubmitting} disabled={isSubmitting}>
{m["channels.edit.action"]()}
</Form.Button>
</div>
</Form.Root>
@@ -0,0 +1,5 @@
import EditChannelForm from "./edit-channel-form.svelte";
export { EditChannelForm };
export { formSchema } from "./schema";
export type { FormSchema } from "./schema";
@@ -0,0 +1,34 @@
import { m } from "$i18n/messages";
import { z } from "zod";
export const formSchema = z.object({
id: z.string(),
names: z
.record(
z.string(),
z
.string()
.min(2, m["channels.add.fields.name.errors.length"]())
.max(50, m["channels.add.fields.name.errors.length"]()),
)
.optional(),
descriptions: z.record(z.string(), z.string()).optional(),
agentIds: z.array(z.string()).default([]),
isPublic: z.boolean().optional().default(false),
requiresConfirmation: z.boolean().optional().default(false),
slotTemplates: z
.array(
z.object({
id: z.string().optional(),
name: z.string().min(1).max(100),
weekdays: z.number().int().min(0).max(127).default(15),
from: z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/),
to: z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/),
duration: z.number().int().min(1).max(1440),
}),
)
.optional()
.default([]),
});
export type FormSchema = typeof formSchema;
@@ -0,0 +1,5 @@
import PauseChannelForm from "./pause-channel-form.svelte";
export { PauseChannelForm };
export { formSchema } from "./schema";
export type { FormSchema } from "./schema";
@@ -0,0 +1,71 @@
<script lang="ts">
import { m } from "$i18n/messages.js";
import * as Form from "$lib/components/ui/form";
import { InlineCode } from "$lib/components/ui/inline-code";
import { Input } from "$lib/components/ui/input";
import { TranslationWithComponent } from "$lib/components/ui/translation-with-component";
import { Text } from "$lib/components/ui/typography";
import type { TChannelWithFullAgents } from "$lib/types/channel";
import { getCurrentTranlslation } from "$lib/utils/localizations";
import { toast } from "svelte-sonner";
import { superForm } from "sveltekit-superforms";
import { zodClient } from "sveltekit-superforms/adapters";
import { formSchema } from ".";
let { entity, done }: { entity: TChannelWithFullAgents; done: () => void } = $props();
const form = superForm(
{ id: entity.id, pause: !entity.pause },
{
validators: zodClient(formSchema),
onResult: async (event) => {
if (event.result.type === "success") {
toast.success(
$formData.pause ? m["channels.pause.success"]() : m["channels.unpause.success"](),
);
done();
} else if (event.result.type === "failure") {
toast.error(
$formData.pause ? m["channels.pause.error"]() : m["channels.unpause.error"](),
);
}
isSubmitting = false;
},
onSubmit: () => (isSubmitting = true),
},
);
let isSubmitting = $state(false);
const { form: formData, enhance } = form;
</script>
<Form.Root {enhance} action="?/pause">
<Text style="sm" class="text-muted-foreground -mt-2 font-normal">
<TranslationWithComponent
translation={$formData.pause
? m["channels.pause.description"]({ name: "{name}" })
: m["channels.unpause.description"]({ name: "{name}" })}
interpolations={[
{ param: "{name}", value: getCurrentTranlslation(entity.names), snippet: inlineCode },
]}
/>
</Text>
<Form.Field {form} name="id" class="hidden">
<Form.Control>
{#snippet children({ props })}
<Input {...props} bind:value={$formData.id} type="hidden" />
{/snippet}
</Form.Control>
</Form.Field>
<div class="mt-6 flex flex-col gap-4">
<Form.Button size="lg" type="submit" isLoading={isSubmitting} disabled={isSubmitting}>
{$formData.pause ? m["channels.pause.action"]() : m["channels.unpause.action"]()}
</Form.Button>
</div>
</Form.Root>
{#snippet inlineCode(value: string | number)}
<InlineCode>{value}</InlineCode>
{/snippet}
@@ -0,0 +1,8 @@
import { z } from "zod";
export const formSchema = z.object({
id: z.string(),
pause: z.boolean(),
});
export type FormSchema = typeof formSchema;
@@ -0,0 +1,17 @@
import { m } from "$i18n/messages";
import type { TNewSlotTemplate } from "$lib/types/channel";
const DEFAULT_SLOT_TEMPLATE: TNewSlotTemplate = {
name: "test",
weekdays: 31,
from: "09:00:00",
to: "17:00:00",
duration: 15,
};
export const generateSlotTemplate = (index: number) => {
return {
...DEFAULT_SLOT_TEMPLATE,
name: m["components.slotTemplate.template"]({ no: index + 1 }),
};
};
@@ -0,0 +1,239 @@
import { ROUTES } from "$lib/const/routes.js";
import logger from "$lib/logger";
import { fail, redirect, type Actions } from "@sveltejs/kit";
import { superValidate } from "sveltekit-superforms";
import { zod } from "sveltekit-superforms/adapters";
import { formSchema as addFormSchema } from "./(components)/add-channel-form";
import { formSchema as editFormSchema } from "./(components)/edit-channel-form";
import { formSchema as deleteFormSchema } from "./(components)/delete-channel-form";
import { formSchema as pauseFormSchema } from "./(components)/pause-channel-form";
import type { TChannel } from "$lib/types/channel";
import { removeEmptyTranslations } from "$lib/utils/localizations";
const log = logger.setContext(import.meta.filename);
export const load = async (event) => {
if (!event.locals.user?.tenantId) {
log.error("User trying to access channels, but has no tenantId");
redirect(302, ROUTES.LOGOUT);
}
const list = event
.fetch(`/api/tenants/${event.locals.user?.tenantId}/channels`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
credentials: "same-origin",
})
.then(async (res) => {
// Logout if session expired
if (res.status === 401) {
redirect(302, ROUTES.LOGOUT);
}
try {
const body = await res.json();
return body.channels as TChannel[];
} catch (error) {
log.error("Failed to parse channels response", { error });
}
});
return {
streamed: {
list,
},
};
};
export const actions: Actions = {
add: async (event) => {
const form = await superValidate(event, zod(addFormSchema));
if (!form.valid) {
log.error("Add channel form is not valid", { errors: form.errors });
return fail(400, {
form: { ...form, data: { ...form.data } },
error: "Form is not valid",
});
}
if (!event.locals.user?.tenantId) {
log.error("User trying to add a channel, but has no tenantId");
redirect(302, ROUTES.LOGOUT);
}
const resp = await event.fetch(`/api/tenants/${event.locals.user?.tenantId}/channels`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "same-origin",
body: JSON.stringify({
names: form.data.names,
descriptions: removeEmptyTranslations(form.data.descriptions),
agentIds: form.data.agentIds,
isPublic: form.data.isPublic,
requiresConfirmation: form.data.requiresConfirmation,
slotTemplates: form.data.slotTemplates,
}),
});
if (resp.status < 400) {
return { form };
} else {
let error = "Unknown error";
try {
const body = await resp.json();
error = body.error;
} catch (e) {
log.error("Failed to parse add channel error response", { error: e });
}
return fail(400, {
form,
error,
});
}
},
edit: async (event) => {
const form = await superValidate(event, zod(editFormSchema));
if (!form.valid) {
log.error("Edit channel form is not valid", { errors: form.errors });
return fail(400, {
form: { ...form, data: { ...form.data } },
error: "Form is not valid",
});
}
if (!event.locals.user?.tenantId) {
log.error("User trying to edit a channel, but has no tenantId");
redirect(302, ROUTES.LOGOUT);
}
const resp = await event.fetch(
`/api/tenants/${event.locals.user?.tenantId}/channels/${form.data.id}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
credentials: "same-origin",
body: JSON.stringify({
names: form.data.names,
descriptions: removeEmptyTranslations(form.data.descriptions),
agentIds: form.data.agentIds,
isPublic: form.data.isPublic,
requiresConfirmation: form.data.requiresConfirmation,
slotTemplates: form.data.slotTemplates,
}),
},
);
if (resp.status < 400) {
return { form };
} else {
let error = "Unknown error";
try {
const body = await resp.json();
error = body.error;
} catch (e) {
log.error("Failed to parse edit channels error response", { error: e });
}
return fail(400, {
form: { ...form, data: { ...form.data } },
error,
});
}
},
delete: async (event) => {
const form = await superValidate(event, zod(deleteFormSchema));
if (!form.valid) {
log.error("Delete channel form is not valid", { errors: form.errors });
return fail(400, {
form: { ...form, data: { ...form.data } },
error: "Form is not valid",
});
}
if (!event.locals.user?.tenantId) {
log.error("User trying to delete a channel, but has no tenantId");
redirect(302, ROUTES.LOGOUT);
}
const resp = await event.fetch(
`/api/tenants/${event.locals.user?.tenantId}/channels/${form.data.id}`,
{
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
credentials: "same-origin",
},
);
if (resp.status < 400) {
return { form };
} else {
let error = "Unknown error";
try {
const body = await resp.json();
error = body.error;
} catch (e) {
log.error("Failed to parse delete channel error response", { error: e });
}
return fail(400, {
form,
error,
});
}
},
pause: async (event) => {
const form = await superValidate(event, zod(pauseFormSchema));
if (!form.valid) {
log.error("Pause/unpause channel form is not valid", { errors: form.errors });
return fail(400, {
form: { ...form, data: { ...form.data } },
error: "Form is not valid",
});
}
if (!event.locals.user?.tenantId) {
log.error("User trying to pause/unpause a channel, but has no tenantId");
redirect(302, ROUTES.LOGOUT);
}
const resp = await event.fetch(
`/api/tenants/${event.locals.user?.tenantId}/channels/${form.data.id}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
credentials: "same-origin",
body: JSON.stringify({
pause: form.data.pause,
}),
},
);
if (resp.status < 400) {
return { form };
} else {
let error = "Unknown error";
try {
const body = await resp.json();
error = body.error;
} catch (e) {
log.error("Failed to parse pause/unpause channel error response", { error: e });
}
return fail(400, {
form,
error,
});
}
},
};
@@ -1,9 +1,42 @@
<script lang="ts">
import { invalidate } from "$app/navigation";
import { m } from "$i18n/messages";
import { MaxPageWidth } from "$lib/components/layouts/max-page-width";
import { SidebarLayout } from "$lib/components/layouts/sidebar-layout";
import EmptyState from "$lib/components/templates/empty-state/center-state.svelte";
import { List, ListItem } from "$lib/components/templates/list";
import { LoadingList } from "$lib/components/templates/loading";
import { Button } from "$lib/components/ui/button";
import { ResponsiveDialog, closeDialog, openDialog } from "$lib/components/ui/responsive-dialog";
import { ROUTES } from "$lib/const/routes";
import { type TChannelWithFullAgents } from "$lib/types/channel";
import { getCurrentTranlslation } from "$lib/utils/localizations";
import EditIcon from "@lucide/svelte/icons/pencil";
import PlusIcon from "@lucide/svelte/icons/plus";
import PauseIcon from "@lucide/svelte/icons/pause";
import PlayIcon from "@lucide/svelte/icons/play";
import UnknownItemIcon from "@lucide/svelte/icons/split";
import DeleteIcon from "@lucide/svelte/icons/trash-2";
import { onMount } from "svelte";
import { AddChannelForm } from "./(components)/add-channel-form";
import DeleteChannelForm from "./(components)/delete-channel-form/delete-channel-form.svelte";
import EditChannelForm from "./(components)/edit-channel-form/edit-channel-form.svelte";
import { PauseChannelForm } from "./(components)/pause-channel-form";
const { data } = $props();
let curItem: TChannelWithFullAgents | null = $state(null);
onMount(() => {
if (history.state["sveltekit:states"]?.action === "add") {
openDialog("add");
history.replaceState({}, "");
}
});
</script>
<svelte:head>
<title>{m["channels.title"]()} - OpenReception</title>
</svelte:head>
<SidebarLayout
breakcrumbs={[
{
@@ -11,4 +44,134 @@
href: ROUTES.DASHBOARD.CHANNELS,
},
]}
></SidebarLayout>
>
<MaxPageWidth maxWidth="lg">
{#await data.streamed.list as TChannelWithFullAgents[]}
<LoadingList title={m["channels.loading"]()} />
{:then items}
<div class="flex flex-col items-start gap-5">
<ResponsiveDialog
id="add"
title={m["channels.add.title"]()}
description={m["channels.add.description"]()}
triggerHidden={items.length === 0}
>
{#snippet triggerLabel()}
<PlusIcon /> {m["channels.add.title"]()}
{/snippet}
<AddChannelForm
done={() => {
invalidate(ROUTES.DASHBOARD.CHANNELS);
closeDialog("add");
}}
/>
</ResponsiveDialog>
{#if items.length > 0}
<List>
{#each items as item (item.id)}
{@const name = getCurrentTranlslation(item.names)}
<ListItem
title={name}
actions={[
{
type: "action",
icon: EditIcon,
label: m["edit"](),
onClick: () => {
curItem = item;
openDialog("edit");
},
},
{
type: "action",
icon: item.pause ? PlayIcon : PauseIcon,
label: item.pause ? m["unpause"]() : m["pause"](),
onClick: () => {
curItem = item;
openDialog("pause");
},
},
{
type: "divider",
},
{
type: "action",
icon: DeleteIcon,
label: m["delete"](),
isDestructive: true,
onClick: () => {
curItem = item;
openDialog("delete");
},
},
]}
badges={item.pause
? [{ label: m["channels.list.badges.paused"](), variant: "outline" }]
: []}
>
{name}
</ListItem>
{/each}
</List>
<ResponsiveDialog
id="edit"
title={m["channels.edit.title"]()}
description={m["channels.edit.description"]()}
triggerHidden={true}
>
{#if curItem}
<EditChannelForm
entity={curItem}
done={() => {
closeDialog("edit");
curItem = null;
invalidate(ROUTES.DASHBOARD.CHANNELS);
}}
/>
{/if}
</ResponsiveDialog>
{#if curItem}
<ResponsiveDialog
id="pause"
title={curItem?.pause ? m["channels.unpause.title"]() : m["channels.pause.title"]()}
triggerHidden={true}
>
<PauseChannelForm
entity={curItem}
done={() => {
closeDialog("pause");
curItem = null;
invalidate(ROUTES.DASHBOARD.CHANNELS);
}}
/>
</ResponsiveDialog>
{/if}
<ResponsiveDialog id="delete" title={m["channels.delete.title"]()} triggerHidden={true}>
{#if curItem}
<DeleteChannelForm
entity={curItem}
done={() => {
closeDialog("delete");
curItem = null;
invalidate(ROUTES.DASHBOARD.CHANNELS);
}}
/>
{/if}
</ResponsiveDialog>
{:else}
<div class="flex w-full flex-col items-center">
<EmptyState
Icon={UnknownItemIcon}
headline={m["channels.empty.title"]()}
description={m["channels.empty.description"]()}
/>
<Button size="lg" onclick={() => openDialog("add")}>
<PlusIcon />
{m["channels.add.title"]()}
</Button>
</div>
{/if}
</div>
{/await}
</MaxPageWidth>
</SidebarLayout>