feat: build dashboard overview UI

This commit is contained in:
2026-06-25 11:40:09 +01:00
parent 9f8675fb30
commit 77cbb1d8d7
5 changed files with 403 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
<script lang="ts">
import ActivityLists from './activity-lists.svelte';
import BillingTrend from './billing-trend.svelte';
import MetricCards from './metric-cards.svelte';
import RoomSummary from './room-summary.svelte';
import { formatDate, formatMoney, formatValue } from '$lib/record-utils';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>Dashboard | Clearity</title>
</svelte:head>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-1">
<h1 class="text-2xl font-semibold tracking-tight">Dashboard</h1>
<p class="text-sm text-muted-foreground">
Operational overview for clients, contracts, bookings, rooms, and billing.
</p>
</div>
<MetricCards metrics={data.metrics} monthLabel={data.meta.monthLabel} {formatMoney} />
<div class="grid gap-4 xl:grid-cols-[minmax(0,1.15fr)_minmax(360px,0.85fr)]">
<BillingTrend records={data.billingTrend} {formatMoney} />
<RoomSummary metrics={data.metrics} summary={data.roomSummary} />
</div>
<ActivityLists
upcomingBookings={data.upcomingBookings}
contractsEndingSoon={data.contractsEndingSoon}
recentInvoices={data.recentInvoices}
{formatDate}
{formatMoney}
{formatValue}
/>
<p class="text-xs text-muted-foreground">{data.meta.overdueInvoiceBasis}</p>
</div>
+163
View File
@@ -0,0 +1,163 @@
<script lang="ts">
import CalendarClockIcon from '@lucide/svelte/icons/calendar-clock';
import FileTextIcon from '@lucide/svelte/icons/file-text';
import ScrollTextIcon from '@lucide/svelte/icons/scroll-text';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import * as Empty from '$lib/components/ui/empty/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import type { PageData } from './$types';
let {
upcomingBookings,
contractsEndingSoon,
recentInvoices,
formatDate,
formatMoney,
formatValue
}: {
upcomingBookings: PageData['upcomingBookings'];
contractsEndingSoon: PageData['contractsEndingSoon'];
recentInvoices: PageData['recentInvoices'];
formatDate: (value: unknown, withTime?: boolean) => string;
formatMoney: (value: unknown) => string;
formatValue: (value: unknown) => string;
} = $props();
</script>
<section class="grid gap-4 xl:grid-cols-3">
<Card.Root>
<Card.Header>
<Card.Title>Upcoming bookings</Card.Title>
<Card.Description>Next room and service reservations.</Card.Description>
<Card.Action
><Button href="/dashboard/bookings" variant="outline" size="sm">View all</Button
></Card.Action
>
</Card.Header>
<Card.Content>
{#if upcomingBookings.length > 0}
<div class="overflow-hidden rounded-lg border">
<Table.Root>
<Table.Body>
{#each upcomingBookings as booking (booking.id)}
<Table.Row>
<Table.Cell>
<div class="font-medium">{booking.roomName}</div>
<div class="text-xs text-muted-foreground">{booking.clientName}</div>
</Table.Cell>
<Table.Cell>
<div>{formatDate(booking.startsAt, true)}</div>
<div class="text-xs text-muted-foreground">
{formatValue(booking.serviceName)}
</div>
</Table.Cell>
<Table.Cell class="text-right">
<Badge variant="secondary" class="capitalize">{booking.status}</Badge>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{:else}
<Empty.Root class="min-h-48 border">
<Empty.Media variant="icon"><CalendarClockIcon /></Empty.Media>
<Empty.Header>
<Empty.Title>No upcoming bookings</Empty.Title>
<Empty.Description>Future bookings will appear here.</Empty.Description>
</Empty.Header>
</Empty.Root>
{/if}
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>Contracts ending soon</Card.Title>
<Card.Description>Active contracts ending in the next 60 days.</Card.Description>
<Card.Action
><Button href="/dashboard/contracts" variant="outline" size="sm">View all</Button
></Card.Action
>
</Card.Header>
<Card.Content>
{#if contractsEndingSoon.length > 0}
<div class="overflow-hidden rounded-lg border">
<Table.Root>
<Table.Body>
{#each contractsEndingSoon as contract (contract.id)}
<Table.Row>
<Table.Cell>
<div class="font-medium">{contract.clientName}</div>
<div class="max-w-48 truncate text-xs text-muted-foreground">
{contract.roomNames.join(', ') || formatValue(contract.serviceName)}
</div>
</Table.Cell>
<Table.Cell>
<div>{formatDate(contract.endDate)}</div>
<div class="text-xs text-muted-foreground">
{formatMoney(contract.licenseFeeGbp)} / month
</div>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{:else}
<Empty.Root class="min-h-48 border">
<Empty.Media variant="icon"><ScrollTextIcon /></Empty.Media>
<Empty.Header>
<Empty.Title>No near-term endings</Empty.Title>
<Empty.Description>Contracts ending soon will be listed here.</Empty.Description>
</Empty.Header>
</Empty.Root>
{/if}
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>Recent invoices</Card.Title>
<Card.Description>Latest generated billing output.</Card.Description>
<Card.Action
><Button href="/dashboard/billing" variant="outline" size="sm">View all</Button
></Card.Action
>
</Card.Header>
<Card.Content>
{#if recentInvoices.length > 0}
<div class="overflow-hidden rounded-lg border">
<Table.Root>
<Table.Body>
{#each recentInvoices as invoice (invoice.id)}
<Table.Row>
<Table.Cell>
<div class="font-medium">{invoice.invoiceNumber}</div>
<div class="text-xs text-muted-foreground">{invoice.clientName}</div>
</Table.Cell>
<Table.Cell>
<div>{formatMoney(invoice.totalGbp)}</div>
<div class="text-xs text-muted-foreground">
Issued {formatDate(invoice.issueDate)}
</div>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{:else}
<Empty.Root class="min-h-48 border">
<Empty.Media variant="icon"><FileTextIcon /></Empty.Media>
<Empty.Header>
<Empty.Title>No invoices yet</Empty.Title>
<Empty.Description>Generated invoices will appear here.</Empty.Description>
</Empty.Header>
</Empty.Root>
{/if}
</Card.Content>
</Card.Root>
</section>
+55
View File
@@ -0,0 +1,55 @@
<script lang="ts">
import ChartNoAxesColumnIncreasingIcon from '@lucide/svelte/icons/chart-no-axes-column-increasing';
import * as Card from '$lib/components/ui/card/index.js';
import * as Empty from '$lib/components/ui/empty/index.js';
import type { PageData } from './$types';
let {
records,
formatMoney
}: {
records: PageData['billingTrend'];
formatMoney: (value: unknown) => string;
} = $props();
const maxValue = $derived(Math.max(...records.map((record) => record.totalGbp), 0));
const hasValues = $derived(records.some((record) => record.totalGbp > 0));
</script>
<Card.Root>
<Card.Header>
<Card.Title>Billing trend</Card.Title>
<Card.Description>Issued invoice totals over the last six months.</Card.Description>
</Card.Header>
<Card.Content>
{#if hasValues}
<div class="grid h-52 grid-cols-6 items-end gap-3" aria-label="Billing trend chart">
{#each records as record (record.month)}
<div class="flex h-full min-w-0 flex-col justify-end gap-2">
<div class="flex min-h-0 flex-1 items-end">
<div
class="w-full rounded-t-md bg-primary/75"
style:height={`${Math.max((record.totalGbp / maxValue) * 100, record.totalGbp > 0 ? 4 : 0)}%`}
title={`${record.label}: ${formatMoney(record.totalGbp)}`}
></div>
</div>
<div class="min-w-0 text-center">
<div class="truncate text-xs font-medium">{formatMoney(record.totalGbp)}</div>
<div class="truncate text-xs text-muted-foreground">{record.label}</div>
</div>
</div>
{/each}
</div>
{:else}
<Empty.Root class="min-h-52 border">
<Empty.Media variant="icon"><ChartNoAxesColumnIncreasingIcon /></Empty.Media>
<Empty.Header>
<Empty.Title>No issued invoices yet</Empty.Title>
<Empty.Description
>Billing totals will appear once invoices are generated.</Empty.Description
>
</Empty.Header>
</Empty.Root>
{/if}
</Card.Content>
</Card.Root>
+77
View File
@@ -0,0 +1,77 @@
<script lang="ts">
import BanknoteIcon from '@lucide/svelte/icons/banknote';
import BriefcaseBusinessIcon from '@lucide/svelte/icons/briefcase-business';
import Building2Icon from '@lucide/svelte/icons/building-2';
import CalendarClockIcon from '@lucide/svelte/icons/calendar-clock';
import FileWarningIcon from '@lucide/svelte/icons/file-warning';
import UsersIcon from '@lucide/svelte/icons/users';
import * as Card from '$lib/components/ui/card/index.js';
import type { PageData } from './$types';
let {
metrics,
monthLabel,
formatMoney
}: {
metrics: PageData['metrics'];
monthLabel: string;
formatMoney: (value: unknown) => string;
} = $props();
const cards = $derived([
{
label: 'Active clients',
value: metrics.activeClients,
detail: 'Unarchived client records',
icon: UsersIcon
},
{
label: 'Active contracts',
value: metrics.activeContracts,
detail: `${formatMoney(metrics.monthlyLicenseFeesGbp)} monthly license fees`,
icon: BriefcaseBusinessIcon
},
{
label: 'Upcoming bookings',
value: metrics.upcomingBookings,
detail: 'Future bookings excluding cancelled',
icon: CalendarClockIcon
},
{
label: 'Past-due invoices',
value: metrics.overdueInvoices,
detail: formatMoney(metrics.overdueInvoicesGbp),
icon: FileWarningIcon
},
{
label: `${monthLabel} billing`,
value: formatMoney(metrics.monthlyBillingGbp),
detail: 'Issued invoice total',
icon: BanknoteIcon
},
{
label: 'Room occupancy',
value: `${metrics.occupancyPercent}%`,
detail: `${metrics.allocatedRooms} of ${metrics.totalRooms} rooms allocated`,
icon: Building2Icon
}
]);
</script>
<section class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{#each cards as card (card.label)}
{@const Icon = card.icon}
<Card.Root size="sm">
<Card.Header>
<Card.Title class="flex items-center gap-2 text-sm text-muted-foreground">
<Icon class="size-4 text-foreground" />
{card.label}
</Card.Title>
</Card.Header>
<Card.Content>
<div class="text-2xl font-semibold tracking-tight">{card.value}</div>
<p class="mt-1 text-xs text-muted-foreground">{card.detail}</p>
</Card.Content>
</Card.Root>
{/each}
</section>
+67
View File
@@ -0,0 +1,67 @@
<script lang="ts">
import Building2Icon from '@lucide/svelte/icons/building-2';
import * as Card from '$lib/components/ui/card/index.js';
import * as Empty from '$lib/components/ui/empty/index.js';
import { Progress } from '$lib/components/ui/progress/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import type { PageData } from './$types';
let {
metrics,
summary
}: {
metrics: PageData['metrics'];
summary: PageData['roomSummary'];
} = $props();
</script>
<Card.Root>
<Card.Header>
<Card.Title>Room inventory</Card.Title>
<Card.Description>Allocation is based on rooms linked to active contracts.</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
{#if metrics.totalRooms > 0}
<div class="space-y-2">
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">Allocated rooms</span>
<span class="font-medium">{metrics.allocatedRooms} / {metrics.totalRooms}</span>
</div>
<Progress value={metrics.occupancyPercent} />
</div>
<div class="overflow-hidden rounded-lg border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Type</Table.Head>
<Table.Head class="text-right">Rooms</Table.Head>
<Table.Head class="text-right">Allocated</Table.Head>
<Table.Head class="text-right">Workstations</Table.Head>
<Table.Head class="text-right">Sq ft.</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each summary.types as type (type.type)}
<Table.Row>
<Table.Cell class="font-medium">{type.label}</Table.Cell>
<Table.Cell class="text-right">{type.total}</Table.Cell>
<Table.Cell class="text-right">{type.allocated}</Table.Cell>
<Table.Cell class="text-right">{type.workstations || 'Not set'}</Table.Cell>
<Table.Cell class="text-right">{type.sqFt || 'Not set'}</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{:else}
<Empty.Root class="min-h-52 border">
<Empty.Media variant="icon"><Building2Icon /></Empty.Media>
<Empty.Header>
<Empty.Title>No rooms configured</Empty.Title>
<Empty.Description>Add rooms before tracking inventory and allocation.</Empty.Description>
</Empty.Header>
</Empty.Root>
{/if}
</Card.Content>
</Card.Root>