mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-08-17 21:25:52 +02:00
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:
co-authored by
Karl Ludwig Weise
parent
86b2b0f869
commit
c292538dc2
Generated
+14
-22
@@ -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
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}[];
|
||||
};
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
|
||||
+187
@@ -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;
|
||||
+85
@@ -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;
|
||||
+203
@@ -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";
|
||||
+71
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user