feat: add client workspace management
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import { asc, eq, isNull } from 'drizzle-orm';
|
||||
import { message, superValidate } from 'sveltekit-superforms/server';
|
||||
import { zod4 } from 'sveltekit-superforms/adapters';
|
||||
import { db } from '$lib/server/db';
|
||||
import { contracts, clients } from '$lib/server/db/schema';
|
||||
import { archiveSchema } from '$lib/schemas/shared.schema';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
async function loadOptions() {
|
||||
const clientRows = await db
|
||||
.select({ id: clients.id, name: clients.name })
|
||||
.from(clients)
|
||||
.where(isNull(clients.archivedAt))
|
||||
.orderBy(asc(clients.name));
|
||||
|
||||
return {
|
||||
clients: clientRows.map((client) => ({ label: client.name, value: client.id }))
|
||||
};
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const records = await db
|
||||
.select()
|
||||
.from(contracts)
|
||||
.where(isNull(contracts.archivedAt))
|
||||
.orderBy(asc(contracts.title));
|
||||
|
||||
return {
|
||||
records,
|
||||
options: await loadOptions(),
|
||||
archiveForm: await superValidate(zod4(archiveSchema), { id: 'contracts-archive' })
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
archive: async (event) => {
|
||||
const form = await superValidate(event, zod4(archiveSchema), { id: 'contracts-archive' });
|
||||
|
||||
if (!form.valid) return message(form, 'Contract id is required.', { status: 400 });
|
||||
|
||||
await db
|
||||
.update(contracts)
|
||||
.set({ archivedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(contracts.id, form.data.id));
|
||||
|
||||
return message(form, 'Contract archived.');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
<script lang="ts">
|
||||
import ContractsTable from './contracts-table.svelte';
|
||||
import ArchiveContractDialog from './archive-contract-dialog.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { superForm } from 'sveltekit-superforms';
|
||||
import { zod4Client } from 'sveltekit-superforms/adapters';
|
||||
import { archiveSchema } from '$lib/schemas/shared.schema';
|
||||
import type { PageData } from './$types';
|
||||
type RecordRow = PageData['records'][number];
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let archivingId = $state<string | null>(null);
|
||||
|
||||
const archivingRecord = $derived(data.records.find((record) => record.id === archivingId));
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
const archiveForm = superForm(data.archiveForm, {
|
||||
validators: zod4Client(archiveSchema),
|
||||
resetForm: false,
|
||||
onUpdated: ({ form }) => handleFormToast(form, 'contracts-archive', () => (archivingId = null)),
|
||||
onError: ({ result }) => toast.error(result.error.message, { id: 'contracts-archive' })
|
||||
});
|
||||
|
||||
const { form: archiveData, enhance: enhanceArchive } = archiveForm;
|
||||
|
||||
function handleFormToast(
|
||||
form: { valid: boolean; message?: string; errors: Record<string, unknown> },
|
||||
id: string,
|
||||
onSuccess: () => void
|
||||
) {
|
||||
if (form.valid) {
|
||||
if (form.message) toast.success(form.message, { id });
|
||||
onSuccess();
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(form.message ?? firstError(form.errors), { id });
|
||||
}
|
||||
|
||||
function firstError(errors: Record<string, unknown>) {
|
||||
for (const value of Object.values(errors)) {
|
||||
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
|
||||
}
|
||||
|
||||
return 'Check the highlighted fields.';
|
||||
}
|
||||
|
||||
function formatValue(value: unknown) {
|
||||
if (value === null || value === undefined || value === '') return 'Not set';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function relationLabel(value: unknown, optionsKey: 'clients') {
|
||||
if (typeof value !== 'string') return 'Not set';
|
||||
return data.options[optionsKey].find((option) => option.value === value)?.label ?? value;
|
||||
}
|
||||
|
||||
function formatMoney(value: unknown) {
|
||||
const amount = typeof value === 'number' ? value : Number(value ?? 0);
|
||||
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(amount);
|
||||
}
|
||||
|
||||
function formatDate(value: unknown, withTime = false) {
|
||||
if (!value || typeof value !== 'string') return 'Not set';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
...(withTime ? { hour: '2-digit', minute: '2-digit' } : {})
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function openArchive(record: RecordRow) {
|
||||
archiveForm.reset({ data: { id: record.id } });
|
||||
archivingId = record.id;
|
||||
}
|
||||
|
||||
function recordName(record: Partial<RecordRow> | undefined) {
|
||||
const item = record as Partial<RecordRow> & {
|
||||
name?: unknown;
|
||||
title?: unknown;
|
||||
invoiceNumber?: unknown;
|
||||
label?: unknown;
|
||||
};
|
||||
return String(
|
||||
item?.name ?? item?.title ?? item?.invoiceNumber ?? item?.label ?? 'this contract'
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Contracts | Clearity</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Contracts</h1>
|
||||
<p class="text-sm text-muted-foreground">Manage client agreements and commercial terms.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ContractsTable
|
||||
{data}
|
||||
{openArchive}
|
||||
{formatValue}
|
||||
{formatDate}
|
||||
{formatMoney}
|
||||
{relationLabel}
|
||||
{recordName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ArchiveContractDialog
|
||||
{archivingRecord}
|
||||
{archiveData}
|
||||
{enhanceArchive}
|
||||
{recordName}
|
||||
onClose={() => (archivingId = null)}
|
||||
/>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
|
||||
let {
|
||||
archivingRecord,
|
||||
archiveData,
|
||||
enhanceArchive,
|
||||
onClose,
|
||||
recordName
|
||||
}: {
|
||||
archivingRecord: any;
|
||||
archiveData: any;
|
||||
enhanceArchive: any;
|
||||
onClose: () => void;
|
||||
recordName: any;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialog.Root open={!!archivingRecord} onOpenChange={(open) => !open && onClose()}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Archive contract?</AlertDialog.Title>
|
||||
<AlertDialog.Description
|
||||
>This will hide {recordName(archivingRecord)} from active contracts. The record will remain in
|
||||
the database.</AlertDialog.Description
|
||||
>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
|
||||
{#if archivingRecord}
|
||||
<form method="POST" action="?/archive" use:enhanceArchive>
|
||||
<input type="hidden" name="id" value={$archiveData.id} />
|
||||
<AlertDialog.Action type="submit" variant="destructive"
|
||||
>Archive contract</AlertDialog.Action
|
||||
>
|
||||
</form>
|
||||
{/if}
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
let {
|
||||
data,
|
||||
openArchive,
|
||||
formatValue,
|
||||
formatDate,
|
||||
formatMoney,
|
||||
relationLabel,
|
||||
recordName
|
||||
}: {
|
||||
data: any;
|
||||
openArchive: any;
|
||||
formatValue: any;
|
||||
formatDate: any;
|
||||
formatMoney: any;
|
||||
relationLabel: any;
|
||||
recordName: any;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#if data.records.length > 0}
|
||||
<div class="overflow-hidden rounded-lg border border-border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Title</Table.Head>
|
||||
<Table.Head>Client</Table.Head>
|
||||
<Table.Head>Starts</Table.Head>
|
||||
<Table.Head>Ends</Table.Head>
|
||||
<Table.Head>Status</Table.Head>
|
||||
<Table.Head>Value</Table.Head>
|
||||
<Table.Head class="w-12"><span class="sr-only">Actions</span></Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each data.records as record (record.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">{formatValue(record.title)}</Table.Cell>
|
||||
<Table.Cell class="">{relationLabel(record.clientId, 'clients')}</Table.Cell>
|
||||
<Table.Cell class="">{formatDate(record.startDate)}</Table.Cell>
|
||||
<Table.Cell class="">{formatDate(record.endDate)}</Table.Cell>
|
||||
<Table.Cell class=""
|
||||
><Badge variant="secondary" class="capitalize">{formatValue(record.status)}</Badge
|
||||
></Table.Cell
|
||||
>
|
||||
<Table.Cell class="">{formatMoney(record.valueGbp)}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
>{#snippet child({ props })}<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Open actions for ${recordName(record)}`}
|
||||
{...props}><EllipsisVerticalIcon /></Button
|
||||
>{/snippet}</DropdownMenu.Trigger
|
||||
>
|
||||
<DropdownMenu.Content align="end" class="w-36">
|
||||
<DropdownMenu.Item variant="destructive" onclick={() => openArchive(record)}
|
||||
>Archive</DropdownMenu.Item
|
||||
>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-lg border p-8 text-center text-sm text-muted-foreground">
|
||||
No contracts have been created yet.
|
||||
</div>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user