Added settings form -wip

This commit is contained in:
Karl Ludwig Weise
2025-10-12 20:28:28 +02:00
parent 6dcb32ff85
commit fd3230fece
20 changed files with 830 additions and 13 deletions
+91 -1
View File
@@ -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."
}
}
}
}
}
+91 -1
View File
@@ -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 });
+22
View File
@@ -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;
};
};
+2
View File
@@ -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>
@@ -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>
+3
View File
@@ -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);