mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-09-10 11:17:39 +02:00
Added settings form -wip
This commit is contained in:
@@ -47,12 +47,21 @@
|
||||
"bothPassSet": "Nur Passphrase oder Passkey darf gesetzt sein",
|
||||
"name": "Muss mindestens 2 Zeichen lang sein",
|
||||
"shortname": "Muss zwischen 4 und 15 Zeichen lang sein",
|
||||
"longname": "Name hinzufügen",
|
||||
"deleteConfirmation": "Muss exakt »{expectedValue}« sein",
|
||||
"noAgentsSelected": "Wähle einen Akteur",
|
||||
"noStartDate": "Beginn ist erforderlich",
|
||||
"noEndDate": "Ende ist erforderlich",
|
||||
"endDateBeforeStartDate": "Das Ende muss nach dem Beginn sein",
|
||||
"endDateTooEarly": "Das Ende muss in der Zukunft liegen"
|
||||
"endDateTooEarly": "Das Ende muss in der Zukunft liegen",
|
||||
"street": "Straße hinzufügen",
|
||||
"houseNo": "Pflichtfeld",
|
||||
"zip": "Postleitzahl hinzufügen",
|
||||
"city": "Stadt hinzufügen",
|
||||
"url": "Ungültige URL",
|
||||
"languages": "Wähle mindestens eine Sprache",
|
||||
"language": "Wähle eine Sprache",
|
||||
"deleteAfterDays": "Wähle einen Zeitpunkt"
|
||||
},
|
||||
"passkey": "Passkey",
|
||||
"name": "Name"
|
||||
@@ -549,5 +558,86 @@
|
||||
"off.site": "Außer Haus",
|
||||
"other": "Andere"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"loading": "Lade Einstellungen...",
|
||||
"form": {
|
||||
"sections": {
|
||||
"general": "Allgemein",
|
||||
"address": "Adresse",
|
||||
"legal": "Rechtliches",
|
||||
"advanced": "Erweitert"
|
||||
},
|
||||
"fields": {
|
||||
"languages": {
|
||||
"title": "Sprachen",
|
||||
"description": "Wähle die Sprachen, die Kunden angezeigt bekommen sollen. Du musst mindestens Eine auswählen.",
|
||||
"placeholder": "Sprachen auswählen"
|
||||
},
|
||||
"defaultLanguage": {
|
||||
"title": "Standardsprache",
|
||||
"description": "Diese Sprache wird angezeigt, wenn die Sprache des Kunden nicht unterstützt wird. In den meisten Fällen sollte dies Englisch sein.",
|
||||
"placeholder": "Standardsprache auswählen",
|
||||
"errors": {
|
||||
"notInLanguages": "Muss eine der ausgewählten Sprachen sein"
|
||||
}
|
||||
},
|
||||
"longName": {
|
||||
"title": "Name",
|
||||
"description": "Der öffentliche Name Deiner Organisation."
|
||||
},
|
||||
"logo": {
|
||||
"title": "Logo (optional)"
|
||||
},
|
||||
"descriptions": {
|
||||
"title": "Beschreibung (optional)",
|
||||
"description": "Kurze Beschreibung deiner Organisation. Wird auf der Startseite angezeigt."
|
||||
},
|
||||
"street": {
|
||||
"title": "Straße"
|
||||
},
|
||||
"number": {
|
||||
"title": "Nr."
|
||||
},
|
||||
"additionalAddressInfo": {
|
||||
"title": "Adresszusatz (optional)",
|
||||
"description": "z.B. Gebäude, Stockwerk, Abteilung"
|
||||
},
|
||||
"zip": {
|
||||
"title": "PLZ"
|
||||
},
|
||||
"city": {
|
||||
"title": "Stadt"
|
||||
},
|
||||
"website": {
|
||||
"title": "Webseite (optional)",
|
||||
"description": "Wird auf der Homepage angezeigt."
|
||||
},
|
||||
"imprint": {
|
||||
"title": "Impressum URL (optional)",
|
||||
"description": "Wird in der Fußzeile angezeigt."
|
||||
},
|
||||
"privacyStatement": {
|
||||
"title": "Datenschutzerklärung URL (optional)",
|
||||
"description": "Wird in der Fußzeile angezeigt."
|
||||
},
|
||||
"autoDeleteDays": {
|
||||
"title": "Automatisches Löschen von Termindaten",
|
||||
"description": "Löscht Termindetails nach dem ausgewählten Zeitraum.",
|
||||
"placeholder": "Zeitraum auswählen",
|
||||
"options": {
|
||||
"30": "nach 30 Tagen",
|
||||
"60": "nach 60 Tagen",
|
||||
"90": "nach 90 Tagen",
|
||||
"180": "nach 180 Tagen",
|
||||
"365": "nach 365 Tagen"
|
||||
}
|
||||
},
|
||||
"requirePhone": {
|
||||
"title": "Telefonnummer erforderlich",
|
||||
"label": "Kunden müssen eine Telefonnummer angeben, wenn sie einen Termin buchen."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,12 +48,21 @@
|
||||
"bothPassSet": "Only one of passphrase or passkey should be provided",
|
||||
"name": "Must be at least 2 characters",
|
||||
"shortname": "Must be between 4 and 15 characters",
|
||||
"longname": "Add a name",
|
||||
"deleteConfirmation": "Must be exactly »{expectedValue}«",
|
||||
"noAgentsSelected": "Select an agent",
|
||||
"noStartDate": "Start date is required",
|
||||
"noEndDate": "End date is required",
|
||||
"endDateBeforeStartDate": "End date must be after start date",
|
||||
"endDateTooEarly": "End date must be in the future"
|
||||
"endDateTooEarly": "End date must be in the future",
|
||||
"street": "Add a street",
|
||||
"houseNo": "Required",
|
||||
"zip": "Add a zip code",
|
||||
"city": "Add a city",
|
||||
"url": "Invalid URL Format",
|
||||
"languages": "Select at least one language",
|
||||
"language": "Select a default language",
|
||||
"deleteAfterDays": "Choose a point in time"
|
||||
},
|
||||
"noPassAtAll": "Either passphrase or passkey is required",
|
||||
"passkey": "Passkey",
|
||||
@@ -558,5 +567,86 @@
|
||||
"off.site": "Off-Site",
|
||||
"other": "Other"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"loading": "Loading settings...",
|
||||
"form": {
|
||||
"sections": {
|
||||
"general": "General",
|
||||
"address": "Address",
|
||||
"legal": "Legal",
|
||||
"advanced": "Advanced"
|
||||
},
|
||||
"fields": {
|
||||
"languages": {
|
||||
"title": "Languages",
|
||||
"description": "Define the languages your customers will see. You need to select at least one.",
|
||||
"placeholder": "Select languages"
|
||||
},
|
||||
"defaultLanguage": {
|
||||
"title": "Default Language",
|
||||
"description": "This is the language your clients will see, if you don't support their language. Often times this should be English.",
|
||||
"placeholder": "Select default language",
|
||||
"errors": {
|
||||
"notInLanguages": "Default language must be one of the selected languages"
|
||||
}
|
||||
},
|
||||
"longName": {
|
||||
"title": "Name",
|
||||
"description": "Public name of your organization."
|
||||
},
|
||||
"logo": {
|
||||
"title": "Logo (optional)"
|
||||
},
|
||||
"descriptions": {
|
||||
"title": "Description (optional)",
|
||||
"description": "Short description of your organization. Will be shown on the home page."
|
||||
},
|
||||
"street": {
|
||||
"title": "Street"
|
||||
},
|
||||
"number": {
|
||||
"title": "No."
|
||||
},
|
||||
"additionalAddressInfo": {
|
||||
"title": "Additional Address Info (optional)",
|
||||
"description": "Apartment, suite, unit, building, floor, etc."
|
||||
},
|
||||
"zip": {
|
||||
"title": "ZIP Code"
|
||||
},
|
||||
"city": {
|
||||
"title": "City"
|
||||
},
|
||||
"website": {
|
||||
"title": "Website (optional)",
|
||||
"description": "Will be shown on the home page."
|
||||
},
|
||||
"imprint": {
|
||||
"title": "Imprint URL (optional)",
|
||||
"description": "Will be linked in the footer."
|
||||
},
|
||||
"privacyStatement": {
|
||||
"title": "Privacy Statement URL (optional)",
|
||||
"description": "Will be linked in the footer."
|
||||
},
|
||||
"autoDeleteDays": {
|
||||
"title": "Delete appointments",
|
||||
"description": "Automatically delete appointments details after a certain period of time.",
|
||||
"placeholder": "Select retention period",
|
||||
"options": {
|
||||
"30": "after 30 days",
|
||||
"60": "after 60 days",
|
||||
"90": "after 90 days",
|
||||
"180": "after 180 days",
|
||||
"365": "after 365 days"
|
||||
}
|
||||
},
|
||||
"requirePhone": {
|
||||
"title": "Require Phone Number",
|
||||
"label": "Require clients to provide a phone number when booking an appointment."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { Headline } from "$lib/components/ui/typography";
|
||||
import { cn } from "$lib/utils";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
title,
|
||||
hideTitleOnMobile,
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: HTMLAttributes<HTMLDivElement> & { hideTitleOnMobile?: boolean } = $props();
|
||||
</script>
|
||||
|
||||
<div class={cn("flex flex-col gap-3", className)} {...restProps}>
|
||||
{#if title}
|
||||
<Headline style="h5" level="h2" class={hideTitleOnMobile ? "hidden md:block" : undefined}>
|
||||
{title}
|
||||
</Headline>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let { class: className, children, ...restProps }: HTMLAttributes<HTMLDivElement> = $props();
|
||||
</script>
|
||||
|
||||
<div class={cn("grid grid-cols-1 gap-10 md:grid-cols-2 xl:grid-cols-3", className)} {...restProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,4 @@
|
||||
import FormGrid from "./form-grid.svelte";
|
||||
import FormGridItem from "./form-grid-item.svelte";
|
||||
|
||||
export { FormGrid, FormGridItem };
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { Text } from "$lib/components/ui/typography";
|
||||
import { cn } from "$lib/utils";
|
||||
import Loader from "@lucide/svelte/icons/loader-2";
|
||||
|
||||
let { class: className = "", title }: { class?: string; title: string } = $props();
|
||||
</script>
|
||||
|
||||
<div class={cn("flex grow flex-col items-center justify-center gap-3", className)}>
|
||||
<Loader class="size-10 animate-spin" strokeWidth={1} />
|
||||
<Text style="sm" class="select-none">{title}</Text>
|
||||
</div>
|
||||
@@ -1,3 +1,4 @@
|
||||
import LoadingList from "./loading-list.svelte";
|
||||
import LoadingList from "./list.svelte";
|
||||
import LoadingCenter from "./center.svelte";
|
||||
|
||||
export { LoadingList };
|
||||
export { LoadingList, LoadingCenter };
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
<script lang="ts">
|
||||
/*
|
||||
Custom Changes
|
||||
* use Text component for consistent typography
|
||||
* custom margin and padding
|
||||
*/
|
||||
import * as FormPrimitive from "formsnap";
|
||||
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||
import { Text } from "../typography";
|
||||
@@ -14,10 +19,10 @@
|
||||
<FormPrimitive.Description
|
||||
bind:ref
|
||||
data-slot="form-description"
|
||||
class={cn("text-muted-foreground -mt-1 text-sm", className)}
|
||||
class={cn("text-muted-foreground -mt-1 ml-1 leading-none", className)}
|
||||
{...restProps}
|
||||
>
|
||||
<Text style="xs" color="medium" class="text-medium">
|
||||
<Text style="xs" color="medium" class="leading-none">
|
||||
{@render children?.()}
|
||||
</Text>
|
||||
</FormPrimitive.Description>
|
||||
|
||||
@@ -5,16 +5,19 @@
|
||||
import type { Snippet } from "svelte";
|
||||
import { Separator } from "../separator";
|
||||
|
||||
const tenantLanguages = ($tenants.currentTenant?.languages || []).map((it) => ({
|
||||
key: it,
|
||||
label: translatedLocales[it as keyof typeof translatedLocales],
|
||||
}));
|
||||
|
||||
type Props = {
|
||||
languages?: string[];
|
||||
children: Snippet<[{ locale: string }]>;
|
||||
};
|
||||
|
||||
let { children }: Props = $props();
|
||||
let { languages, children }: Props = $props();
|
||||
|
||||
let tenantLanguages = $derived(
|
||||
(languages ? languages : $tenants.currentTenant?.languages || []).map((it) => ({
|
||||
key: it,
|
||||
label: translatedLocales[it as keyof typeof translatedLocales],
|
||||
})),
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if tenantLanguages.length > 1}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
h2: "text-3xl font-semibold transition-colors first:mt-0",
|
||||
h3: "text-2xl font-semibold",
|
||||
h4: "text-xl font-semibold",
|
||||
h5: "text-lg font-semibold",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -208,6 +208,7 @@ export class TenantAdminService {
|
||||
try {
|
||||
const tenant = new TenantAdminService(id);
|
||||
tenant.#config = await TenantConfig.create(id);
|
||||
console.log("tenant config >", tenant.#config);
|
||||
const data = await tenant.#db
|
||||
?.select()
|
||||
.from(centralSchema.tenant)
|
||||
@@ -215,6 +216,9 @@ export class TenantAdminService {
|
||||
.limit(1);
|
||||
if (data) {
|
||||
tenant.#tenant = data[0];
|
||||
console.log("data >", data);
|
||||
} else {
|
||||
console.log("no data!!!", data);
|
||||
}
|
||||
|
||||
log.debug("Tenant service loaded successfully", { tenantId: id });
|
||||
|
||||
@@ -1,3 +1,25 @@
|
||||
import type { SelectTenant } from "$lib/server/db/central-schema";
|
||||
|
||||
export type TTenant = Pick<SelectTenant, "id" | "shortName" | "languages" | "setupState">;
|
||||
|
||||
export type TTenantSettings = Omit<SelectTenant, "databaseUrl" | "setupState" | "logo"> & {
|
||||
logo: string | undefined;
|
||||
address: {
|
||||
street: string;
|
||||
number: string;
|
||||
additionalAddressInfo?: string;
|
||||
zip: string;
|
||||
city: string;
|
||||
};
|
||||
legal: {
|
||||
website?: string;
|
||||
imprint?: string;
|
||||
privacyStatement?: string;
|
||||
};
|
||||
settings: {
|
||||
languages: string[];
|
||||
defaultLanguage: string;
|
||||
autoDeleteDays: number;
|
||||
requirePhone: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import Button from "$lib/components/ui/button/button.svelte";
|
||||
import { HorizontalPagePadding, PageWithClaim } from "$lib/components/ui/page";
|
||||
import { Headline, Text } from "$lib/components/ui/typography";
|
||||
|
||||
@@ -21,6 +22,7 @@
|
||||
{isEnvOk ? "OK" : "NOT OK"}
|
||||
{/await}.
|
||||
</Text>
|
||||
<Button href="/dashboard">Go to Dashboard</Button>
|
||||
</HorizontalPagePadding>
|
||||
</main>
|
||||
</PageWithClaim>
|
||||
|
||||
+339
@@ -0,0 +1,339 @@
|
||||
<script lang="ts">
|
||||
import { m } from "$i18n/messages.js";
|
||||
import * as Form from "$lib/components/ui/form";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import type { TTenantSettings } from "$lib/types/tenant";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { superForm } from "sveltekit-superforms";
|
||||
import { zodClient } from "sveltekit-superforms/adapters";
|
||||
import { formSchema } from "./schema";
|
||||
import { FormGrid, FormGridItem } from "$lib/components/templates/form-grid";
|
||||
import { tenants } from "$lib/stores/tenants";
|
||||
import { get } from "svelte/store";
|
||||
import * as Select from "$lib/components/ui/select";
|
||||
import { supportedLocales, translatedLocales } from "$lib/const/locales";
|
||||
import { InputCroppedImageBlob } from "$lib/components/ui/input-cropped-image-blob";
|
||||
import DefaultOrgIcon from "@lucide/svelte/icons/landmark";
|
||||
import { LanguageTabs } from "$lib/components/ui/language-tabs";
|
||||
import { Textarea } from "$lib/components/ui/textarea";
|
||||
import { CheckboxWithLabel } from "$lib/components/ui/checkbox-with-label";
|
||||
|
||||
let { entity }: { entity: TTenantSettings } = $props();
|
||||
|
||||
const autoDeleteDaysOptions = [
|
||||
{ key: 30, label: m["settings.form.fields.autoDeleteDays.options.30"]() },
|
||||
{ key: 60, label: m["settings.form.fields.autoDeleteDays.options.60"]() },
|
||||
{ key: 90, label: m["settings.form.fields.autoDeleteDays.options.90"]() },
|
||||
{ key: 180, label: m["settings.form.fields.autoDeleteDays.options.180"]() },
|
||||
{ key: 365, label: m["settings.form.fields.autoDeleteDays.options.365"]() },
|
||||
];
|
||||
const tenantLocales = get(tenants).currentTenant?.languages ?? []; // TODO: allow update within form
|
||||
const availableLocales = supportedLocales.map((it) => ({
|
||||
key: it,
|
||||
label: translatedLocales[it as keyof typeof translatedLocales],
|
||||
}));
|
||||
const form = superForm(
|
||||
{
|
||||
id: entity.id,
|
||||
shortName: entity.shortName,
|
||||
longName: entity.longName,
|
||||
logo: entity.logo,
|
||||
descriptions: tenantLocales.reduce(
|
||||
(acc, locale) => ({ ...acc, [locale]: "" }),
|
||||
{} as { [key: string]: string },
|
||||
),
|
||||
address: entity.address,
|
||||
legal: entity.legal,
|
||||
settings: {
|
||||
languages: entity.settings.languages,
|
||||
defaultLanguage: entity.settings.defaultLanguage,
|
||||
autoDeleteDays: entity.settings.autoDeleteDays,
|
||||
requirePhone: entity.settings.requirePhone || false,
|
||||
},
|
||||
},
|
||||
{
|
||||
dataType: "json",
|
||||
validators: zodClient(formSchema),
|
||||
onResult: async (event) => {
|
||||
if (event.result.type === "success") {
|
||||
toast.success(m["tenants.edit.success"]());
|
||||
} else if (event.result.type === "failure") {
|
||||
toast.error(m["tenants.edit.error"]());
|
||||
}
|
||||
isSubmitting = false;
|
||||
},
|
||||
onSubmit: () => (isSubmitting = true),
|
||||
},
|
||||
);
|
||||
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
const { form: formData, enhance } = form;
|
||||
</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>
|
||||
<FormGrid class="grid-flow-dense">
|
||||
<FormGridItem
|
||||
title={m["settings.form.sections.general"]()}
|
||||
hideTitleOnMobile={true}
|
||||
class="row-span-3"
|
||||
>
|
||||
<Form.Field {form} name="settings.languages">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{m["settings.form.fields.languages.title"]()}</Form.Label>
|
||||
<Select.Root
|
||||
type="multiple"
|
||||
bind:value={$formData.settings.languages}
|
||||
name={props.name}
|
||||
onValueChange={(v) => ($formData.settings.languages = v)}
|
||||
>
|
||||
<Select.Trigger {...props} class="w-full">
|
||||
{$formData.settings.languages.length > 0
|
||||
? $formData.settings.languages
|
||||
.map((id) => availableLocales.find((x) => x.key === id)?.label)
|
||||
.join(", ")
|
||||
: m["settings.form.fields.languages.placeholder"]()}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each availableLocales as locale (locale.key)}
|
||||
<Select.Item value={locale.key}>{locale.label}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<Form.Description>
|
||||
{m["settings.form.fields.languages.description"]()}
|
||||
</Form.Description>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="settings.defaultLanguage">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{m["settings.form.fields.defaultLanguage.title"]()}</Form.Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
bind:value={$formData.settings.defaultLanguage}
|
||||
name={props.name}
|
||||
onValueChange={(v) => ($formData.settings.defaultLanguage = v)}
|
||||
>
|
||||
<Select.Trigger {...props} class="w-full">
|
||||
{$formData.settings.defaultLanguage
|
||||
? availableLocales.find((x) => x.key === $formData.settings.defaultLanguage)
|
||||
?.label
|
||||
: m["settings.form.fields.defaultLanguage.placeholder"]()}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each $formData.settings.languages as language (language)}
|
||||
<Select.Item value={language}>
|
||||
{availableLocales.find((x) => x.key === language)?.label}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<Form.Description>
|
||||
{m["settings.form.fields.defaultLanguage.description"]()}
|
||||
</Form.Description>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="longName">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{m["form.name"]()}</Form.Label>
|
||||
<Input {...props} bind:value={$formData.longName} type="text" autocomplete="off" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.Description>
|
||||
{m["settings.form.fields.longName.description"]()}
|
||||
</Form.Description>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="logo">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{m["settings.form.fields.logo.title"]()}</Form.Label>
|
||||
<InputCroppedImageBlob
|
||||
{...props}
|
||||
bind:value={$formData.logo}
|
||||
FallbackIcon={DefaultOrgIcon}
|
||||
/>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<LanguageTabs languages={$formData.settings.languages}>
|
||||
{#snippet children({ locale })}
|
||||
<Form.Field {form} name={`descriptions.${locale}`}>
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{m["settings.form.fields.descriptions.title"]()}</Form.Label>
|
||||
<Textarea {...props} bind:value={$formData.descriptions[locale]} maxlength={200} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.Description
|
||||
>{m["settings.form.fields.descriptions.description"]()}</Form.Description
|
||||
>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
{/snippet}
|
||||
</LanguageTabs>
|
||||
</FormGridItem>
|
||||
<FormGridItem title={m["settings.form.sections.address"]()}>
|
||||
<div class="flex justify-between gap-3">
|
||||
<Form.Field {form} name="address.street" class="flex-4/5">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{m["settings.form.fields.street.title"]()}</Form.Label>
|
||||
<Input {...props} bind:value={$formData.address.street} type="text" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="address.number" class="flex-1/5">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{m["settings.form.fields.number.title"]()}</Form.Label>
|
||||
<Input {...props} bind:value={$formData.address.number} type="text" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
</div>
|
||||
<Form.Field {form} name="address.additionalAddressInfo">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{m["settings.form.fields.additionalAddressInfo.title"]()}</Form.Label>
|
||||
<Input {...props} bind:value={$formData.address.additionalAddressInfo} type="text" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<div class="flex justify-between gap-3">
|
||||
<Form.Field {form} name="address.zip" class="flex-2/5">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{m["settings.form.fields.zip.title"]()}</Form.Label>
|
||||
<Input {...props} bind:value={$formData.address.zip} type="text" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="address.city" class="flex-3/5">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{m["settings.form.fields.city.title"]()}</Form.Label>
|
||||
<Input {...props} bind:value={$formData.address.city} type="text" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
</div>
|
||||
</FormGridItem>
|
||||
<FormGridItem title={m["settings.form.sections.legal"]()}>
|
||||
<Form.Field {form} name="legal.website" class="flex-3/5">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{m["settings.form.fields.website.title"]()}</Form.Label>
|
||||
<Input {...props} bind:value={$formData.legal.website} type="text" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.Description>
|
||||
{m["settings.form.fields.website.description"]()}
|
||||
</Form.Description>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="legal.imprint" class="flex-3/5">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{m["settings.form.fields.imprint.title"]()}</Form.Label>
|
||||
<Input {...props} bind:value={$formData.legal.imprint} type="text" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.Description>
|
||||
{m["settings.form.fields.imprint.description"]()}
|
||||
</Form.Description>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="legal.privacyStatement" class="flex-3/5">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{m["settings.form.fields.privacyStatement.title"]()}</Form.Label>
|
||||
<Input {...props} bind:value={$formData.legal.privacyStatement} type="text" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.Description>
|
||||
{m["settings.form.fields.privacyStatement.description"]()}
|
||||
</Form.Description>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
</FormGridItem>
|
||||
<FormGridItem title={m["settings.form.sections.advanced"]()} class="xl:col-start-3">
|
||||
<Form.Field {form} name="settings.autoDeleteDays">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{m["settings.form.fields.autoDeleteDays.title"]()}</Form.Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={$formData.settings.autoDeleteDays.toString()}
|
||||
name={props.name}
|
||||
onValueChange={(v) => ($formData.settings.autoDeleteDays = parseInt(v))}
|
||||
>
|
||||
<Select.Trigger {...props} class="w-full">
|
||||
{$formData.settings.autoDeleteDays
|
||||
? autoDeleteDaysOptions.find((x) => x.key === $formData.settings.autoDeleteDays)
|
||||
?.label
|
||||
: m["settings.form.fields.autoDeleteDays.placeholder"]()}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each autoDeleteDaysOptions as option (option.key)}
|
||||
<Select.Item value={option.key.toString()}>
|
||||
{option.label}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="settings.requirePhone">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{m["settings.form.fields.requirePhone.title"]()}</Form.Label>
|
||||
<CheckboxWithLabel
|
||||
{...props}
|
||||
bind:value={$formData.settings.requirePhone}
|
||||
label={m["settings.form.fields.requirePhone.label"]()}
|
||||
onCheckedChange={(v) => {
|
||||
$formData.settings.requirePhone = v;
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
</FormGridItem>
|
||||
</FormGrid>
|
||||
|
||||
<div class="mt-6 flex flex-col gap-4">
|
||||
<Form.Button
|
||||
size="lg"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
class="sm:ml-auto"
|
||||
>
|
||||
{m["tenants.edit.action"]()}
|
||||
</Form.Button>
|
||||
</div>
|
||||
</Form.Root>
|
||||
@@ -0,0 +1,5 @@
|
||||
import EditSettingsForm from "./edit-settings-form.svelte";
|
||||
|
||||
export { EditSettingsForm };
|
||||
export { formSchema } from "./schema";
|
||||
export type { FormSchema } from "./schema";
|
||||
@@ -0,0 +1,41 @@
|
||||
import { m } from "$i18n/messages";
|
||||
import { z } from "zod";
|
||||
|
||||
export const formSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
shortName: z.string(),
|
||||
longName: z.string().min(1, { message: m["form.errors.longname"]() }),
|
||||
logo: z.string().optional().or(z.literal("")),
|
||||
descriptions: z.record(z.string(), z.string()).optional(),
|
||||
address: z.object({
|
||||
street: z.string().min(1, { message: m["form.errors.street"]() }),
|
||||
number: z.string().min(1, { message: m["form.errors.houseNo"]() }),
|
||||
additionalAddressInfo: z.string().optional().or(z.literal("")),
|
||||
zip: z.string().min(4, { message: m["form.errors.zip"]() }),
|
||||
city: z.string().min(1, { message: m["form.errors.city"]() }),
|
||||
}),
|
||||
legal: z.object({
|
||||
website: z.string().url({ message: m["form.errors.url"]() }).optional().default(""),
|
||||
imprint: z.string().url({ message: m["form.errors.url"]() }).optional().default(""),
|
||||
privacyStatement: z.string().url({ message: m["form.errors.url"]() }).optional().default(""),
|
||||
}),
|
||||
settings: z.object({
|
||||
languages: z.array(z.string()).min(1, { message: m["form.errors.languages"]() }),
|
||||
defaultLanguage: z.string().min(1, { message: m["form.errors.language"]() }),
|
||||
autoDeleteDays: z.number().min(30, { message: m["form.errors.deleteAfterDays"]() }),
|
||||
requirePhone: z.boolean().optional().default(false),
|
||||
}),
|
||||
})
|
||||
.superRefine(({ settings }, ctx) => {
|
||||
// TODO: Fix, error is not shown in the UI
|
||||
if (!settings.languages.includes(settings.defaultLanguage)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: m["settings.form.fields.defaultLanguage.errors.notInLanguages"](),
|
||||
path: ["settings", "defaultLanguage"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type FormSchema = typeof formSchema;
|
||||
@@ -0,0 +1,145 @@
|
||||
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 editFormSchema } from "./(components)/edit-settings-form";
|
||||
import { removeEmptyTranslations } from "$lib/utils/localizations";
|
||||
|
||||
const log = logger.setContext(import.meta.filename);
|
||||
|
||||
export const load = async (event) => {
|
||||
// To edit seetings you must have connected tenant
|
||||
if (!event.locals.user?.tenantId) {
|
||||
log.warn("No tenant ID found for user while loading settings");
|
||||
throw redirect(302, ROUTES.DASHBOARD.MAIN);
|
||||
}
|
||||
|
||||
const item = event
|
||||
.fetch(`/api/tenants/${event.locals.user?.tenantId}`, {
|
||||
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();
|
||||
console.log("+++++++++++++", body);
|
||||
return {
|
||||
id: "some-id",
|
||||
shortName: "praxisdrdoe",
|
||||
longName: "Praxis Dr. Jane Doe",
|
||||
logo: "",
|
||||
descriptions: { en: "some-description" },
|
||||
address: {
|
||||
street: "Musterstrasse",
|
||||
number: "1",
|
||||
additionalAddressInfo: "Hinterhaus",
|
||||
zip: "20000",
|
||||
city: "Musterstadt",
|
||||
},
|
||||
legal: {
|
||||
website: "https://example.com",
|
||||
imprint: "https://example.com/imprint",
|
||||
privacyStatement: "https://example.com/privacy",
|
||||
},
|
||||
settings: {
|
||||
languages: ["en"],
|
||||
defaultLanguage: "en",
|
||||
autoDeleteDays: 90,
|
||||
requirePhone: true,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
log.error("Failed to parse agents response", { error });
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
form: await superValidate(zod(editFormSchema)),
|
||||
streamed: {
|
||||
item,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
edit: async (event) => {
|
||||
const form = await superValidate(event, zod(editFormSchema));
|
||||
|
||||
if (!form.valid) {
|
||||
log.error("Edit settings form is not valid", { errors: form.errors });
|
||||
return fail(400, {
|
||||
form: { ...form, data: { ...form.data } },
|
||||
error: "Form is not valid",
|
||||
});
|
||||
}
|
||||
|
||||
console.log("req body", {
|
||||
languages: form.data.settings.languages,
|
||||
defaultLanguage: form.data.settings.defaultLanguage,
|
||||
longName: form.data.longName,
|
||||
logo: form.data.logo,
|
||||
descriptions: removeEmptyTranslations(form.data.descriptions),
|
||||
street: form.data.address.street,
|
||||
number: form.data.address.number,
|
||||
additionalAddressInfo: form.data.address.additionalAddressInfo,
|
||||
zip: form.data.address.zip,
|
||||
city: form.data.address.city,
|
||||
website: form.data.legal.website,
|
||||
imprint: form.data.legal.imprint,
|
||||
privacyStatement: form.data.legal.privacyStatement,
|
||||
autoDeleteDays: form.data.settings.autoDeleteDays,
|
||||
requirePhone: form.data.settings.requirePhone ?? false,
|
||||
});
|
||||
return { form };
|
||||
|
||||
const resp = await event.fetch(`/api/tenants/${form.data.id}/config`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({
|
||||
languages: form.data.settings.languages,
|
||||
defaultLanguage: form.data.settings.defaultLanguage,
|
||||
longName: form.data.longName,
|
||||
logo: form.data.logo,
|
||||
descriptions: removeEmptyTranslations(form.data.descriptions),
|
||||
street: form.data.address.street,
|
||||
number: form.data.address.number,
|
||||
additionalAddressInfo: form.data.address.additionalAddressInfo,
|
||||
zip: form.data.address.zip,
|
||||
city: form.data.address.city,
|
||||
website: form.data.legal.website,
|
||||
imprint: form.data.legal.imprint,
|
||||
privacyStatement: form.data.legal.privacyStatement,
|
||||
autoDeleteDays: form.data.settings.autoDeleteDays,
|
||||
requirePhone: form.data.settings.requirePhone ?? false,
|
||||
}),
|
||||
});
|
||||
|
||||
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 settings error response", { error: e });
|
||||
}
|
||||
return fail(400, {
|
||||
form: { ...form, data: { ...form.data } },
|
||||
error,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1,7 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { m } from "$i18n/messages";
|
||||
import { MaxPageWidth } from "$lib/components/layouts/max-page-width";
|
||||
import { SidebarLayout } from "$lib/components/layouts/sidebar-layout";
|
||||
import { ROUTES } from "$lib/const/routes";
|
||||
import { Card } from "$lib/components/ui/card";
|
||||
import { Headline } from "$lib/components/ui/typography";
|
||||
import { EditSettingsForm } from "./(components)/edit-settings-form";
|
||||
import { LoadingCenter } from "$lib/components/templates/loading";
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<SidebarLayout
|
||||
@@ -11,4 +18,15 @@
|
||||
href: ROUTES.DASHBOARD.SETTINGS,
|
||||
},
|
||||
]}
|
||||
></SidebarLayout>
|
||||
>
|
||||
<MaxPageWidth maxWidth="lg">
|
||||
{#await data.streamed.item}
|
||||
<LoadingCenter title={m["settings.loading"]()} class="h-[80vh] py-20" />
|
||||
{:then item}
|
||||
<Card>
|
||||
<Headline style="h4" level="h1">Settings</Headline>
|
||||
<EditSettingsForm entity={item} />
|
||||
</Card>
|
||||
{/await}
|
||||
</MaxPageWidth>
|
||||
</SidebarLayout>
|
||||
|
||||
@@ -439,6 +439,8 @@ export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
|
||||
try {
|
||||
const tenantId = params.id;
|
||||
console.log("🙈🙈🙈🙈🙈🙈🙈🙈🙈🙈🙈🙈🙈🙈🙈🙈🙈🙈🙈🙈🙈");
|
||||
console.log("tenantId", tenantId);
|
||||
|
||||
if (!tenantId) {
|
||||
throw new ValidationError(ERRORS.TENANTS.NO_TENANT_ID);
|
||||
@@ -453,6 +455,7 @@ export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
|
||||
const tenantService = await TenantAdminService.getTenantById(tenantId);
|
||||
const tenantData = tenantService.tenantData;
|
||||
console.log("tenantData", tenantData);
|
||||
|
||||
if (!tenantData) {
|
||||
throw new NotFoundError(ERRORS.TENANTS.NOT_FOUND);
|
||||
|
||||
Reference in New Issue
Block a user