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:
2026-06-06 14:14:20 +01:00
parent cbd2fb2921
commit 7c57a22a3c
7 changed files with 510 additions and 77 deletions
@@ -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>