diff --git a/src/lib/components/app-sidebar.svelte b/src/lib/components/app-sidebar.svelte index f05f719..70b70b6 100644 --- a/src/lib/components/app-sidebar.svelte +++ b/src/lib/components/app-sidebar.svelte @@ -54,12 +54,20 @@ - - - - - - {#snippet child({ props })} - -
- -
-
- {selectedCentre.name} - {selectedCentre.workspace} -
- -
- {/snippet} -
- - {#each centres as centre (centre.name)} - (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} - > -
- -
-
- {centre.name} - {centre.workspace} -
- {#if centre.name === selectedCentre.name} - - {/if} -
- {/each} -
-
-
-
diff --git a/src/lib/components/organization-switcher.svelte b/src/lib/components/organization-switcher.svelte new file mode 100644 index 0000000..26e36a8 --- /dev/null +++ b/src/lib/components/organization-switcher.svelte @@ -0,0 +1,350 @@ + + +{#snippet organizationFields(prefix: string, organization?: Organization)} +
+
+ + Name + + + + Workspace + + +
+ + + Address + + + + Address line 2 + + +
+ + City + + + + Region + + +
+
+ + Postcode + + + + Country + + +
+
+{/snippet} + +{#snippet bankFields(prefix: string, organization?: Organization)} +
+
+ + Bank details +
+
+ + Bank name + + + + Account name + + +
+
+ + Account number + + + + Sort code + + +
+
+ + IBAN + + + + SWIFT/BIC + + +
+
+{/snippet} + + + + + + {#snippet child({ props })} + +
+ +
+
+ {activeOrganization.name} + {activeOrganization.workspace} +
+ +
+ {/snippet} +
+ + {#each organizations as organization (organization.id)} +
+ + + + + +
+ {/each} + + + + + + + +
+
+
+
+ + + + + Organization settings + Update details for the active organization and invoice payments. + + +
+ + + + {@render organizationFields('organization-settings', activeOrganization)} + {@render bankFields('organization-settings', activeOrganization)} + + + + {#snippet child({ props })} + + {/snippet} + + + +
+
+
+ + + + + Create organization + Add organization details for records and invoice payments. + + +
+ + + {@render organizationFields('organization-create')} + {@render bankFields('organization-create')} + + + + {#snippet child({ props })} + + {/snippet} + + + +
+
+
diff --git a/src/lib/schemas/organizations.schema.ts b/src/lib/schemas/organizations.schema.ts new file mode 100644 index 0000000..eecbaef --- /dev/null +++ b/src/lib/schemas/organizations.schema.ts @@ -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); diff --git a/src/routes/dashboard/organizations/+server.ts b/src/routes/dashboard/organizations/+server.ts new file mode 100644 index 0000000..b27b278 --- /dev/null +++ b/src/routes/dashboard/organizations/+server.ts @@ -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)); +}; diff --git a/src/routes/dashboard/organizations/current/+server.ts b/src/routes/dashboard/organizations/current/+server.ts new file mode 100644 index 0000000..b854ecc --- /dev/null +++ b/src/routes/dashboard/organizations/current/+server.ts @@ -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'))); +}; diff --git a/src/routes/dashboard/switch-organization/+server.ts b/src/routes/dashboard/switch-organization/+server.ts new file mode 100644 index 0000000..bcf7727 --- /dev/null +++ b/src/routes/dashboard/switch-organization/+server.ts @@ -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' + ); +};