feat: manage organizations from switcher
Rename the centre switcher to organization switcher and wire it to loaded organizations. Add dropdown actions for switching organizations, creating a new organization, and editing the active organization in dialogs. Persist organization changes through dashboard endpoints while preserving the current redirect path.
This commit is contained in:
@@ -54,12 +54,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import CentreSwitcher from './centre-switcher.svelte';
|
import OrganizationSwitcher from './organization-switcher.svelte';
|
||||||
import SearchForm from './search-form.svelte';
|
import SearchForm from './search-form.svelte';
|
||||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||||
import type { ComponentProps } from 'svelte';
|
import type { ComponentProps } from 'svelte';
|
||||||
|
|
||||||
let { ref = $bindable(null), ...restProps }: ComponentProps<typeof Sidebar.Root> = $props();
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
organizations,
|
||||||
|
activeOrganization,
|
||||||
|
...restProps
|
||||||
|
}: ComponentProps<typeof Sidebar.Root> & {
|
||||||
|
organizations: ComponentProps<typeof OrganizationSwitcher>['organizations'];
|
||||||
|
activeOrganization: ComponentProps<typeof OrganizationSwitcher>['activeOrganization'];
|
||||||
|
} = $props();
|
||||||
|
|
||||||
type NavUrl = (typeof data.navMain)[number]['items'][number]['url'];
|
type NavUrl = (typeof data.navMain)[number]['items'][number]['url'];
|
||||||
|
|
||||||
@@ -81,7 +89,7 @@
|
|||||||
|
|
||||||
<Sidebar.Root {...restProps} bind:ref>
|
<Sidebar.Root {...restProps} bind:ref>
|
||||||
<Sidebar.Header>
|
<Sidebar.Header>
|
||||||
<CentreSwitcher />
|
<OrganizationSwitcher {organizations} {activeOrganization} />
|
||||||
<SearchForm />
|
<SearchForm />
|
||||||
</Sidebar.Header>
|
</Sidebar.Header>
|
||||||
<Sidebar.Content>
|
<Sidebar.Content>
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Building2Icon from '@lucide/svelte/icons/building-2';
|
|
||||||
import CheckIcon from '@lucide/svelte/icons/check';
|
|
||||||
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
|
|
||||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
|
||||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
|
||||||
|
|
||||||
type Centre = {
|
|
||||||
name: string;
|
|
||||||
workspace: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const centres: Centre[] = [
|
|
||||||
{
|
|
||||||
name: 'Steel City Stadium',
|
|
||||||
workspace: 'Blueprint Workspace'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Lodmund Court',
|
|
||||||
workspace: 'Blueprint Workspace'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
let selectedCentre = $state(centres[0]);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Sidebar.Menu>
|
|
||||||
<Sidebar.MenuItem>
|
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger>
|
|
||||||
{#snippet child({ props })}
|
|
||||||
<Sidebar.MenuButton
|
|
||||||
size="lg"
|
|
||||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
|
|
||||||
>
|
|
||||||
<Building2Icon class="size-4" />
|
|
||||||
</div>
|
|
||||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
|
||||||
<span class="truncate font-semibold">{selectedCentre.name}</span>
|
|
||||||
<span class="truncate text-xs">{selectedCentre.workspace}</span>
|
|
||||||
</div>
|
|
||||||
<ChevronsUpDownIcon class="ms-auto" />
|
|
||||||
</Sidebar.MenuButton>
|
|
||||||
{/snippet}
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content class="w-(--bits-dropdown-menu-anchor-width)" align="start">
|
|
||||||
{#each centres as centre (centre.name)}
|
|
||||||
<DropdownMenu.Item
|
|
||||||
onSelect={() => (selectedCentre = centre)}
|
|
||||||
class="gap-2 focus:bg-sidebar-accent focus:text-sidebar-accent-foreground data-[selected=true]:bg-sidebar-accent data-[selected=true]:text-sidebar-accent-foreground"
|
|
||||||
data-selected={centre.name === selectedCentre.name}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground group-focus/dropdown-menu-item:bg-sidebar-primary group-focus/dropdown-menu-item:text-sidebar-primary-foreground"
|
|
||||||
>
|
|
||||||
<Building2Icon class="!text-sidebar-primary-foreground size-4" />
|
|
||||||
</div>
|
|
||||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
|
||||||
<span class="truncate font-semibold">{centre.name}</span>
|
|
||||||
<span class="truncate text-xs text-muted-foreground">{centre.workspace}</span>
|
|
||||||
</div>
|
|
||||||
{#if centre.name === selectedCentre.name}
|
|
||||||
<CheckIcon class="ms-auto" />
|
|
||||||
{/if}
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
{/each}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</Sidebar.MenuItem>
|
|
||||||
</Sidebar.Menu>
|
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Building2Icon from '@lucide/svelte/icons/building-2';
|
||||||
|
import CheckIcon from '@lucide/svelte/icons/check';
|
||||||
|
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
|
||||||
|
import LandmarkIcon from '@lucide/svelte/icons/landmark';
|
||||||
|
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||||
|
import SettingsIcon from '@lucide/svelte/icons/settings';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
|
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||||
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||||
|
import * as Field from '$lib/components/ui/field/index.js';
|
||||||
|
import { Input } from '$lib/components/ui/input/index.js';
|
||||||
|
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||||
|
|
||||||
|
type Organization = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
workspace: string;
|
||||||
|
addressLine1?: string | null;
|
||||||
|
addressLine2?: string | null;
|
||||||
|
city?: string | null;
|
||||||
|
region?: string | null;
|
||||||
|
postcode?: string | null;
|
||||||
|
country?: string | null;
|
||||||
|
bankName?: string | null;
|
||||||
|
bankAccountName?: string | null;
|
||||||
|
bankAccountNumber?: string | null;
|
||||||
|
bankSortCode?: string | null;
|
||||||
|
bankIban?: string | null;
|
||||||
|
bankSwift?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
organizations,
|
||||||
|
activeOrganization
|
||||||
|
}: {
|
||||||
|
organizations: Organization[];
|
||||||
|
activeOrganization: Organization;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let settingsOpen = $state(false);
|
||||||
|
let createOpen = $state(false);
|
||||||
|
const redirectTo = $derived(page.url.pathname + page.url.search);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet organizationFields(prefix: string, organization?: Organization)}
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<div class="grid gap-2 sm:grid-cols-2">
|
||||||
|
<Field.Field>
|
||||||
|
<Field.Label for={`${prefix}-name`}>Name</Field.Label>
|
||||||
|
<Input
|
||||||
|
id={`${prefix}-name`}
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
value={organization?.name ?? ''}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field.Field>
|
||||||
|
<Field.Field>
|
||||||
|
<Field.Label for={`${prefix}-workspace`}>Workspace</Field.Label>
|
||||||
|
<Input
|
||||||
|
id={`${prefix}-workspace`}
|
||||||
|
name="workspace"
|
||||||
|
type="text"
|
||||||
|
value={organization?.workspace ?? 'Blueprint Workspace'}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field.Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field.Field>
|
||||||
|
<Field.Label for={`${prefix}-address-line-1`}>Address</Field.Label>
|
||||||
|
<Input
|
||||||
|
id={`${prefix}-address-line-1`}
|
||||||
|
name="addressLine1"
|
||||||
|
type="text"
|
||||||
|
value={organization?.addressLine1 ?? ''}
|
||||||
|
/>
|
||||||
|
</Field.Field>
|
||||||
|
<Field.Field>
|
||||||
|
<Field.Label for={`${prefix}-address-line-2`}>Address line 2</Field.Label>
|
||||||
|
<Input
|
||||||
|
id={`${prefix}-address-line-2`}
|
||||||
|
name="addressLine2"
|
||||||
|
type="text"
|
||||||
|
value={organization?.addressLine2 ?? ''}
|
||||||
|
/>
|
||||||
|
</Field.Field>
|
||||||
|
<div class="grid gap-2 sm:grid-cols-2">
|
||||||
|
<Field.Field>
|
||||||
|
<Field.Label for={`${prefix}-city`}>City</Field.Label>
|
||||||
|
<Input id={`${prefix}-city`} name="city" type="text" value={organization?.city ?? ''} />
|
||||||
|
</Field.Field>
|
||||||
|
<Field.Field>
|
||||||
|
<Field.Label for={`${prefix}-region`}>Region</Field.Label>
|
||||||
|
<Input
|
||||||
|
id={`${prefix}-region`}
|
||||||
|
name="region"
|
||||||
|
type="text"
|
||||||
|
value={organization?.region ?? ''}
|
||||||
|
/>
|
||||||
|
</Field.Field>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2 sm:grid-cols-2">
|
||||||
|
<Field.Field>
|
||||||
|
<Field.Label for={`${prefix}-postcode`}>Postcode</Field.Label>
|
||||||
|
<Input
|
||||||
|
id={`${prefix}-postcode`}
|
||||||
|
name="postcode"
|
||||||
|
type="text"
|
||||||
|
value={organization?.postcode ?? ''}
|
||||||
|
/>
|
||||||
|
</Field.Field>
|
||||||
|
<Field.Field>
|
||||||
|
<Field.Label for={`${prefix}-country`}>Country</Field.Label>
|
||||||
|
<Input
|
||||||
|
id={`${prefix}-country`}
|
||||||
|
name="country"
|
||||||
|
type="text"
|
||||||
|
value={organization?.country ?? 'United Kingdom'}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field.Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet bankFields(prefix: string, organization?: Organization)}
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<div class="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<LandmarkIcon class="size-4" />
|
||||||
|
<span>Bank details</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2 sm:grid-cols-2">
|
||||||
|
<Field.Field>
|
||||||
|
<Field.Label for={`${prefix}-bank-name`}>Bank name</Field.Label>
|
||||||
|
<Input
|
||||||
|
id={`${prefix}-bank-name`}
|
||||||
|
name="bankName"
|
||||||
|
type="text"
|
||||||
|
value={organization?.bankName ?? ''}
|
||||||
|
/>
|
||||||
|
</Field.Field>
|
||||||
|
<Field.Field>
|
||||||
|
<Field.Label for={`${prefix}-bank-account-name`}>Account name</Field.Label>
|
||||||
|
<Input
|
||||||
|
id={`${prefix}-bank-account-name`}
|
||||||
|
name="bankAccountName"
|
||||||
|
type="text"
|
||||||
|
value={organization?.bankAccountName ?? ''}
|
||||||
|
/>
|
||||||
|
</Field.Field>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2 sm:grid-cols-2">
|
||||||
|
<Field.Field>
|
||||||
|
<Field.Label for={`${prefix}-bank-account-number`}>Account number</Field.Label>
|
||||||
|
<Input
|
||||||
|
id={`${prefix}-bank-account-number`}
|
||||||
|
name="bankAccountNumber"
|
||||||
|
type="text"
|
||||||
|
value={organization?.bankAccountNumber ?? ''}
|
||||||
|
/>
|
||||||
|
</Field.Field>
|
||||||
|
<Field.Field>
|
||||||
|
<Field.Label for={`${prefix}-bank-sort-code`}>Sort code</Field.Label>
|
||||||
|
<Input
|
||||||
|
id={`${prefix}-bank-sort-code`}
|
||||||
|
name="bankSortCode"
|
||||||
|
type="text"
|
||||||
|
value={organization?.bankSortCode ?? ''}
|
||||||
|
/>
|
||||||
|
</Field.Field>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2 sm:grid-cols-2">
|
||||||
|
<Field.Field>
|
||||||
|
<Field.Label for={`${prefix}-bank-iban`}>IBAN</Field.Label>
|
||||||
|
<Input
|
||||||
|
id={`${prefix}-bank-iban`}
|
||||||
|
name="bankIban"
|
||||||
|
type="text"
|
||||||
|
value={organization?.bankIban ?? ''}
|
||||||
|
/>
|
||||||
|
</Field.Field>
|
||||||
|
<Field.Field>
|
||||||
|
<Field.Label for={`${prefix}-bank-swift`}>SWIFT/BIC</Field.Label>
|
||||||
|
<Input
|
||||||
|
id={`${prefix}-bank-swift`}
|
||||||
|
name="bankSwift"
|
||||||
|
type="text"
|
||||||
|
value={organization?.bankSwift ?? ''}
|
||||||
|
/>
|
||||||
|
</Field.Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<Sidebar.Menu>
|
||||||
|
<Sidebar.MenuItem>
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<Sidebar.MenuButton
|
||||||
|
size="lg"
|
||||||
|
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
|
||||||
|
>
|
||||||
|
<Building2Icon class="size-4" />
|
||||||
|
</div>
|
||||||
|
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||||
|
<span class="truncate font-semibold">{activeOrganization.name}</span>
|
||||||
|
<span class="truncate text-xs">{activeOrganization.workspace}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronsUpDownIcon class="ms-auto" />
|
||||||
|
</Sidebar.MenuButton>
|
||||||
|
{/snippet}
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content class="w-(--bits-dropdown-menu-anchor-width)" align="start">
|
||||||
|
{#each organizations as organization (organization.id)}
|
||||||
|
<form method="POST" action="/dashboard/switch-organization">
|
||||||
|
<input type="hidden" name="organizationId" value={organization.id} />
|
||||||
|
<input type="hidden" name="redirectTo" value={redirectTo} />
|
||||||
|
<DropdownMenu.Item
|
||||||
|
class="gap-2 focus:bg-sidebar-accent focus:text-sidebar-accent-foreground data-[selected=true]:bg-sidebar-accent data-[selected=true]:text-sidebar-accent-foreground"
|
||||||
|
data-selected={organization.id === activeOrganization.id}
|
||||||
|
>
|
||||||
|
<button type="submit" class="contents">
|
||||||
|
<div
|
||||||
|
class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground group-focus/dropdown-menu-item:bg-sidebar-primary group-focus/dropdown-menu-item:text-sidebar-primary-foreground"
|
||||||
|
>
|
||||||
|
<Building2Icon class="size-4 !text-sidebar-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||||
|
<span class="truncate font-semibold">{organization.name}</span>
|
||||||
|
<span class="truncate text-xs text-muted-foreground"
|
||||||
|
>{organization.workspace}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{#if organization.id === activeOrganization.id}
|
||||||
|
<CheckIcon class="ms-auto" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</form>
|
||||||
|
{/each}
|
||||||
|
<DropdownMenu.Separator />
|
||||||
|
<DropdownMenu.Item
|
||||||
|
class="gap-2 focus:bg-sidebar-accent focus:text-sidebar-accent-foreground"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="contents"
|
||||||
|
onclick={() => {
|
||||||
|
settingsOpen = true;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex aspect-square size-8 items-center justify-center rounded-lg border bg-background text-foreground"
|
||||||
|
>
|
||||||
|
<SettingsIcon class="size-4" />
|
||||||
|
</div>
|
||||||
|
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||||
|
<span class="truncate font-semibold">Organization settings</span>
|
||||||
|
<span class="truncate text-xs text-muted-foreground">Edit current organization</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
class="gap-2 focus:bg-sidebar-accent focus:text-sidebar-accent-foreground"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="contents"
|
||||||
|
onclick={() => {
|
||||||
|
createOpen = true;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex aspect-square size-8 items-center justify-center rounded-lg border bg-background text-foreground"
|
||||||
|
>
|
||||||
|
<PlusIcon class="size-4" />
|
||||||
|
</div>
|
||||||
|
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||||
|
<span class="truncate font-semibold">Create organization</span>
|
||||||
|
<span class="truncate text-xs text-muted-foreground">Add a new workspace</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</Sidebar.MenuItem>
|
||||||
|
</Sidebar.Menu>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open={settingsOpen}>
|
||||||
|
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>Organization settings</Dialog.Title>
|
||||||
|
<Dialog.Description
|
||||||
|
>Update details for the active organization and invoice payments.</Dialog.Description
|
||||||
|
>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
<form method="POST" action="/dashboard/organizations/current" class="grid gap-5">
|
||||||
|
<input type="hidden" name="id" value={activeOrganization.id} />
|
||||||
|
<input type="hidden" name="redirectTo" value={redirectTo} />
|
||||||
|
|
||||||
|
{@render organizationFields('organization-settings', activeOrganization)}
|
||||||
|
{@render bankFields('organization-settings', activeOrganization)}
|
||||||
|
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Dialog.Close>
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<Button variant="outline" {...props}>Cancel</Button>
|
||||||
|
{/snippet}
|
||||||
|
</Dialog.Close>
|
||||||
|
<Button type="submit">Save settings</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</form>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open={createOpen}>
|
||||||
|
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>Create organization</Dialog.Title>
|
||||||
|
<Dialog.Description
|
||||||
|
>Add organization details for records and invoice payments.</Dialog.Description
|
||||||
|
>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
<form method="POST" action="/dashboard/organizations" class="grid gap-5">
|
||||||
|
<input type="hidden" name="redirectTo" value={redirectTo} />
|
||||||
|
|
||||||
|
{@render organizationFields('organization-create')}
|
||||||
|
{@render bankFields('organization-create')}
|
||||||
|
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Dialog.Close>
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<Button variant="outline" {...props}>Cancel</Button>
|
||||||
|
{/snippet}
|
||||||
|
</Dialog.Close>
|
||||||
|
<Button type="submit">Create organization</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</form>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { idSchema, optionalText, requiredText } from './shared.schema';
|
||||||
|
|
||||||
|
const organizationDetailsSchema = z.object({
|
||||||
|
name: requiredText('Organization name'),
|
||||||
|
workspace: requiredText('Workspace', 120).default('Blueprint Workspace'),
|
||||||
|
addressLine1: optionalText(160),
|
||||||
|
addressLine2: optionalText(160),
|
||||||
|
city: optionalText(100),
|
||||||
|
region: optionalText(100),
|
||||||
|
postcode: optionalText(24),
|
||||||
|
country: requiredText('Country', 80).default('United Kingdom'),
|
||||||
|
bankName: optionalText(120),
|
||||||
|
bankAccountName: optionalText(120),
|
||||||
|
bankAccountNumber: optionalText(40),
|
||||||
|
bankSortCode: optionalText(40),
|
||||||
|
bankIban: optionalText(64),
|
||||||
|
bankSwift: optionalText(32)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const organizationCreateSchema = organizationDetailsSchema.extend({
|
||||||
|
redirectTo: optionalText(500).default('/dashboard')
|
||||||
|
});
|
||||||
|
|
||||||
|
export const organizationEditSchema = organizationDetailsSchema.extend(idSchema.shape);
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { redirect, error } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { organizations } from '$lib/server/db/schema';
|
||||||
|
import { organizationCreateSchema } from '$lib/schemas/organizations.schema';
|
||||||
|
import { setActiveOrganization } from '$lib/server/organizations';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
function safeRedirect(value: string) {
|
||||||
|
return value.startsWith('/') && !value.startsWith('//') ? value : '/dashboard';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ locals, request }) => {
|
||||||
|
if (!locals.user) {
|
||||||
|
redirect(303, '/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const parsed = organizationCreateSchema.safeParse(Object.fromEntries(formData));
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
error(400, 'Check the organization details and try again.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
await db.insert(organizations).values({
|
||||||
|
id,
|
||||||
|
name: parsed.data.name,
|
||||||
|
workspace: parsed.data.workspace,
|
||||||
|
addressLine1: parsed.data.addressLine1 || null,
|
||||||
|
addressLine2: parsed.data.addressLine2 || null,
|
||||||
|
city: parsed.data.city || null,
|
||||||
|
region: parsed.data.region || null,
|
||||||
|
postcode: parsed.data.postcode || null,
|
||||||
|
country: parsed.data.country,
|
||||||
|
bankName: parsed.data.bankName || null,
|
||||||
|
bankAccountName: parsed.data.bankAccountName || null,
|
||||||
|
bankAccountNumber: parsed.data.bankAccountNumber || null,
|
||||||
|
bankSortCode: parsed.data.bankSortCode || null,
|
||||||
|
bankIban: parsed.data.bankIban || null,
|
||||||
|
bankSwift: parsed.data.bankSwift || null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
});
|
||||||
|
|
||||||
|
await setActiveOrganization(locals, id);
|
||||||
|
|
||||||
|
redirect(303, safeRedirect(parsed.data.redirectTo));
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { and, eq, isNull } from 'drizzle-orm';
|
||||||
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { organizations } from '$lib/server/db/schema';
|
||||||
|
import { loadOrganizationContext } from '$lib/server/organizations';
|
||||||
|
import { organizationEditSchema } from '$lib/schemas/organizations.schema';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
function safeRedirect(value: FormDataEntryValue | null) {
|
||||||
|
return typeof value === 'string' && value.startsWith('/') && !value.startsWith('//')
|
||||||
|
? value
|
||||||
|
: '/dashboard';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ locals, request }) => {
|
||||||
|
if (!locals.user) {
|
||||||
|
redirect(303, '/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { activeOrganizationId } = await loadOrganizationContext(locals);
|
||||||
|
const formData = await request.formData();
|
||||||
|
const parsed = organizationEditSchema.safeParse(Object.fromEntries(formData));
|
||||||
|
|
||||||
|
if (!parsed.success || parsed.data.id !== activeOrganizationId) {
|
||||||
|
error(400, 'Check the organization details and try again.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(organizations)
|
||||||
|
.set({
|
||||||
|
name: parsed.data.name,
|
||||||
|
workspace: parsed.data.workspace,
|
||||||
|
addressLine1: parsed.data.addressLine1 || null,
|
||||||
|
addressLine2: parsed.data.addressLine2 || null,
|
||||||
|
city: parsed.data.city || null,
|
||||||
|
region: parsed.data.region || null,
|
||||||
|
postcode: parsed.data.postcode || null,
|
||||||
|
country: parsed.data.country,
|
||||||
|
bankName: parsed.data.bankName || null,
|
||||||
|
bankAccountName: parsed.data.bankAccountName || null,
|
||||||
|
bankAccountNumber: parsed.data.bankAccountNumber || null,
|
||||||
|
bankSortCode: parsed.data.bankSortCode || null,
|
||||||
|
bankIban: parsed.data.bankIban || null,
|
||||||
|
bankSwift: parsed.data.bankSwift || null,
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(organizations.id, activeOrganizationId),
|
||||||
|
eq(organizations.id, parsed.data.id),
|
||||||
|
isNull(organizations.archivedAt)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
redirect(303, safeRedirect(formData.get('redirectTo')));
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { setActiveOrganization } from '$lib/server/organizations';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ locals, request }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const organizationId = formData.get('organizationId');
|
||||||
|
const redirectTo = formData.get('redirectTo');
|
||||||
|
|
||||||
|
if (typeof organizationId === 'string') {
|
||||||
|
await setActiveOrganization(locals, organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect(
|
||||||
|
303,
|
||||||
|
typeof redirectTo === 'string' && redirectTo.startsWith('/') ? redirectTo : '/dashboard'
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user