Add dashboard overview, campaigns and events

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2026-02-18 22:05:28 +01:00
parent e5d2c0ba65
commit 184a299ba0
4 changed files with 759 additions and 378 deletions
@@ -0,0 +1,38 @@
<script>
import { page } from '$app/stores';
// determine active tab based on current route
$: currentPath = $page.url.pathname;
</script>
<nav class="mb-6 border-b border-gray-200 dark:border-gray-700">
<div class="flex gap-6">
<a
href="/dashboard"
class="px-1 py-3 text-sm font-medium border-b-2 transition-colors
{currentPath === '/dashboard'
? 'border-cta-blue dark:border-highlight-blue text-cta-blue dark:text-highlight-blue'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'}"
>
Overview
</a>
<a
href="/dashboard/campaigns"
class="px-1 py-3 text-sm font-medium border-b-2 transition-colors
{currentPath === '/dashboard/campaigns'
? 'border-cta-blue dark:border-highlight-blue text-cta-blue dark:text-highlight-blue'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'}"
>
Campaigns
</a>
<a
href="/dashboard/events"
class="px-1 py-3 text-sm font-medium border-b-2 transition-colors
{currentPath === '/dashboard/events'
? 'border-cta-blue dark:border-highlight-blue text-cta-blue dark:text-highlight-blue'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'}"
>
Events
</a>
</div>
</nav>
+31 -378
View File
@@ -7,27 +7,17 @@
import { onMount } from 'svelte';
import { showIsLoading, hideIsLoading } from '$lib/store/loading.js';
import { addToast } from '$lib/store/toast';
import { newTableURLParams } from '$lib/service/tableURLParams';
import Table from '$lib/components/table/Table.svelte';
import TableRow from '$lib/components/table/TableRow.svelte';
import TableCell from '$lib/components/table/TableCell.svelte';
import TableCellEmpty from '$lib/components/table/TableCellEmpty.svelte';
import TableCellAction from '$lib/components/table/TableCellAction.svelte';
import TableViewButton from '$lib/components/table/TableViewButton.svelte';
import { goto } from '$app/navigation';
import TableDropDownEllipsis from '$lib/components/table/TableDropDownEllipsis.svelte';
import TestLabel from '$lib/components/TestLabel.svelte';
import AutoRefresh from '$lib/components/AutoRefresh.svelte';
import StatsCard from '$lib/components/StatsCard.svelte';
import CampaignCalender from '$lib/components/CampaignCalendar.svelte';
import CampaignTrendChart from '$lib/components/CampaignTrendChart.svelte';
import CheckboxField from '$lib/components/CheckboxField.svelte';
import { fetchAllRows } from '$lib/utils/api-utils';
import { tick } from 'svelte';
import EventName from '$lib/components/table/EventName.svelte';
import TextFieldSelect from '$lib/components/TextFieldSelect.svelte';
import { autoRefreshStore, setPageAutoRefresh } from '$lib/store/autoRefresh';
import { BiMap } from '$lib/utils/maps';
import { goto } from '$app/navigation';
import DashboardNav from '$lib/components/DashboardNav.svelte';
// services
const appStateService = AppStateService.instance;
@@ -44,61 +34,24 @@
// local state
let contextCompanyID = null;
let contextCompanyName = '';
let scheduledTableURLParams = newTableURLParams({
prefix: 'scheduled',
sortBy: 'send_start_at',
sortOrder: 'desc',
noScroll: true
});
let activeTableURLParams = newTableURLParams({
prefix: 'active',
sortBy: 'send_start_at',
sortOrder: 'desc',
noScroll: true
});
let eventsTableURLParams = newTableURLParams({
prefix: 'events',
sortBy: 'created_at',
sortOrder: 'desc',
noScroll: true
});
let isActiveCampaignsLoading = false;
let isUpcomingCampaignsLoading = false;
let isEventsLoading = false;
let active = 0;
let scheduled = 0;
let finished = 0;
let repeatOffenders = 0;
let finishedCustomStats = 0;
let calendarCampaigns = [];
let activeCampaigns = [];
let activeCampaignsHasNextPage = true;
let scheduledCampaigns = [];
let scheduledCampaignsHasNextPage = true;
let events = [];
let eventsHasNextPage = true;
let eventTypesIDToNameMap = {};
let availableEventTypes = [];
let selectedEventType = '';
let campaignStats = [];
let isCampaignStatsLoading = false;
let calendarStartDate = null;
let calendarEndDate = null;
// Toggle for including test campaigns
let includeTestCampaigns = false;
// Use consistent colors with campaign detail page - these are already well-suited for both light and dark modes
// Handler for when toggle changes
// handler for when toggle changes
const handleToggleChange = async () => {
// Wait for binding to update
await tick();
// Refresh all data with new toggle state
await refresh(false);
};
@@ -117,15 +70,6 @@
contextCompanyName = context.companyName;
}
refresh();
activeTableURLParams.onChange(() => refreshActiveCampaigns(true));
scheduledTableURLParams.onChange(() => refreshScheduledCampaigns(true));
eventsTableURLParams.onChange(() => refreshEvents(true));
return () => {
activeTableURLParams.unsubscribe();
scheduledTableURLParams.unsubscribe();
eventsTableURLParams.unsubscribe();
};
});
const refresh = async (showLoading = true) => {
@@ -144,11 +88,7 @@
active = res.data.active;
scheduled = res.data.upcoming;
finished = res.data.finished;
await setEventTypes();
await refreshCalendarCampaings();
await refreshActiveCampaigns(showLoading);
await refreshScheduledCampaigns(showLoading);
await refreshEvents(showLoading);
await refreshCampaignStats(showLoading);
} catch (e) {
addToast('Failed to load data', 'Error');
@@ -178,110 +118,6 @@
} catch (e) {
addToast('Failed to load calendar campaigns', 'Error');
console.error('Failed to load calendar campaigns', e);
} finally {
}
};
const refreshActiveCampaigns = async (showLoading = true) => {
if (showLoading) {
isActiveCampaignsLoading = true;
}
try {
const options = {
page: activeTableURLParams.currentPage,
perPage: activeTableURLParams.perPage,
sortBy: activeTableURLParams.sortBy,
sortOrder: activeTableURLParams.sortOrder,
search: activeTableURLParams.search,
includeTest: includeTestCampaigns
};
const res = await api.campaign.getAllActive(options, contextCompanyID);
if (!res.success) {
throw res.error;
}
activeCampaigns = res.data.rows;
activeCampaignsHasNextPage = res.data.hasNextPage;
} catch (e) {
addToast('Failed to load active campaigns', 'Error');
console.error('Failed to load active campaigns', e);
} finally {
if (showLoading) {
isActiveCampaignsLoading = false;
}
}
};
const refreshScheduledCampaigns = async (showLoading = true) => {
if (showLoading) {
isUpcomingCampaignsLoading = true;
}
try {
const options = {
page: scheduledTableURLParams.currentPage,
perPage: scheduledTableURLParams.perPage,
sortBy: scheduledTableURLParams.sortBy,
sortOrder: scheduledTableURLParams.sortOrder,
search: scheduledTableURLParams.search,
includeTest: includeTestCampaigns
};
const res = await api.campaign.getAllUpcoming(options, contextCompanyID);
if (!res.success) {
throw res.error;
}
scheduledCampaigns = res.data.rows;
scheduledCampaignsHasNextPage = res.data.hasNextPage;
} catch (e) {
addToast('Failed to load scheduled campaigns', 'Error');
console.error('Failed to load scheduled campaigns', e);
} finally {
if (showLoading) {
isUpcomingCampaignsLoading = false;
}
}
};
const setEventTypes = async () => {
try {
const res = await api.campaign.getAllEventTypes();
if (!res.success) {
addToast('Failed to load event types', 'Error');
console.error('failed to load event types', res.error);
return;
}
res.data.map((t) => (eventTypesIDToNameMap[t.id] = t.name));
availableEventTypes = res.data.map((t) => ({ value: t.id, label: t.name }));
availableEventTypes.unshift({ value: '', label: 'All Events' });
} catch (e) {
addToast('Failed to load event types', 'Error');
console.error('failed to load event types', e);
}
};
const refreshEvents = async (showIsLoading = true) => {
try {
if (showIsLoading) {
isEventsLoading = true;
}
const options = {
page: eventsTableURLParams.page,
perPage: eventsTableURLParams.perPage,
sortBy: eventsTableURLParams.sortBy,
sortOrder: eventsTableURLParams.sortOrder,
search: eventsTableURLParams.search,
includeTest: includeTestCampaigns
};
const res = await api.campaign.getAllEvents(options, contextCompanyID);
if (res.success) {
events = res.data?.rows ?? [];
eventsHasNextPage = res.data?.hasNextPage ?? false;
}
} catch (e) {
addToast('Failed to load events', 'Error');
console.error('failed to load events', e);
} finally {
if (showIsLoading) {
isEventsLoading = false;
}
}
};
@@ -308,8 +144,6 @@
throw res.error;
}
campaignStats = res.data.rows || [];
// stats without a campaign ID is custom stats
finishedCustomStats = res.data.rows.filter((c) => !c.campaignId).length;
} catch (e) {
addToast('Failed to load campaign statistics', 'Error');
console.error('Failed to load campaign statistics', e);
@@ -319,17 +153,16 @@
}
}
};
/** @param {string} id */
const onClickViewCampaign = (id) => {
goto(`/campaign/${id}`);
};
</script>
<HeadTitle title="Dashboard" />
<main>
<Headline>Dashboard</Headline>
<DashboardNav />
<div class="flex justify-between items-center mb-6">
<Headline>Dashboard</Headline>
<SubHeadline>Overview</SubHeadline>
<div class="flex items-center gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<span class="font-semibold text-slate-600 dark:text-gray-300 whitespace-nowrap">
@@ -381,11 +214,11 @@
</div>
</div>
</div>
<AutoRefresh
isLoading={false}
pageId="dashboard"
onRefresh={async () => {
// refresh all data
let res = await api.campaign.getStats(contextCompanyID, {
includeTest: includeTestCampaigns
});
@@ -398,51 +231,6 @@
scheduled = res.data.upcoming;
finished = res.data.finished;
// refresh table data directly like campaign page does
const activeOptions = {
page: activeTableURLParams.currentPage,
perPage: activeTableURLParams.perPage,
sortBy: activeTableURLParams.sortBy,
sortOrder: activeTableURLParams.sortOrder,
search: activeTableURLParams.search,
includeTest: includeTestCampaigns
};
const activeRes = await api.campaign.getAllActive(activeOptions, contextCompanyID);
if (activeRes.success) {
activeCampaigns = activeRes.data.rows;
activeCampaignsHasNextPage = activeRes.data.hasNextPage;
}
// refresh scheduled campaigns
const scheduledOptions = {
page: scheduledTableURLParams.currentPage,
perPage: scheduledTableURLParams.perPage,
sortBy: scheduledTableURLParams.sortBy,
sortOrder: scheduledTableURLParams.sortOrder,
search: scheduledTableURLParams.search,
includeTest: includeTestCampaigns
};
const scheduledRes = await api.campaign.getAllUpcoming(scheduledOptions, contextCompanyID);
if (scheduledRes.success) {
scheduledCampaigns = scheduledRes.data.rows;
scheduledCampaignsHasNextPage = scheduledRes.data.hasNextPage;
}
// refresh events
const eventsOptions = {
page: eventsTableURLParams.currentPage,
perPage: eventsTableURLParams.perPage,
sortBy: eventsTableURLParams.sortBy,
sortOrder: eventsTableURLParams.sortOrder,
search: eventsTableURLParams.search,
includeTest: includeTestCampaigns
};
const eventsRes = await api.campaign.getAllEvents(eventsOptions, contextCompanyID);
if (eventsRes.success) {
events = eventsRes.data?.rows ?? [];
eventsHasNextPage = eventsRes.data?.hasNextPage ?? false;
}
const statsRes = await api.campaign.getAllCampaignStats(contextCompanyID);
if (statsRes.success) {
campaignStats = [];
@@ -453,22 +241,22 @@
await refreshCalendarCampaings();
}}
/>
{#if contextCompanyName}
<SubHeadline>{contextCompanyName}</SubHeadline>
{/if}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8 mt-4">
<a href="/campaign">
<a href="/dashboard/campaigns">
<StatsCard
title="Active Campaigns"
title="Active campaigns"
value={active}
borderColor="border-campaign-active"
iconColor="text-campaign-active"
borderColor="border-blue-500"
iconColor="text-blue-500"
>
<svg
slot="icon"
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 ml-2 text-campaign-active"
class="h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -483,17 +271,16 @@
</StatsCard>
</a>
<a href="/campaign">
<a href="/dashboard/campaigns">
<StatsCard
title="Scheduled Campaigns"
title="Upcoming campaigns"
value={scheduled}
borderColor="border-campaign-scheduled"
iconColor="text-campaign-scheduled"
borderColor="border-indigo-500"
iconColor="text-indigo-500"
>
<svg
slot="icon"
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 ml-2 text-campaign-scheduled"
class="h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -508,17 +295,16 @@
</StatsCard>
</a>
<a href="/campaign">
<a href="/dashboard/campaigns">
<StatsCard
title="Completed Campaigns"
value={finished > 0 ? finished : finishedCustomStats}
borderColor="border-message-read"
iconColor="text-message-read"
title="Completed campaigns"
value={finished}
borderColor="border-green-500"
iconColor="text-green-500"
>
<svg
slot="icon"
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 ml-2 text-message-read"
class="h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -535,15 +321,14 @@
<a href="/recipient">
<StatsCard
title="Repeat Offenders"
title="Repeat offenders"
value={repeatOffenders}
borderColor="border-submitted-data"
iconColor="text-submitted-data"
borderColor="border-red-500"
iconColor="text-red-500"
>
<svg
slot="icon"
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 ml-2 text-submitted-data"
class="h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -568,146 +353,14 @@
/>
</div>
<SubHeadline>Recent events</SubHeadline>
<div class="min-h-[300px] mb-8">
<Table
columns={[
{ column: 'Time', size: 'large' },
{ column: 'Event', size: 'large' },
{ column: 'Campaign', size: 'large' },
{ column: 'Email', size: 'large' },
...(contextCompanyID ? [] : [{ column: 'Company', size: 'large' }])
]}
pagination={eventsTableURLParams}
plural="events"
hasData={!!events.length}
hasNextPage={eventsHasNextPage}
isGhost={isEventsLoading}
noSearch={true}
hasActions={false}
>
{#each events as event (event.id)}
<TableRow>
<TableCell isDate isRelative value={event.createdAt} />
<TableCell>
<EventName eventName={eventTypesIDToNameMap[event.eventID]} />
</TableCell>
<TableCell>
{#if event.campaign?.name}
<a href={`/campaign/${event.campaignID}`} class="block w-full py-1">
{event.campaign.name}
</a>
{/if}
</TableCell>
<TableCell>
{#if event.recipient?.email}
<a href={`/recipient/${event.recipient.id}`} class="block w-full py-1">
{event.recipient.email}
</a>
{/if}
</TableCell>
{#if !contextCompanyID}
<TableCell>
{#if event.campaign?.company?.name}
{event.campaign.company.name}
{/if}
</TableCell>
{/if}
</TableRow>
{/each}
</Table>
</div>
<SubHeadline>{contextCompanyName ? 'Calendar' : 'Shared Calendar'}</SubHeadline>
<div class="mb-8 min-h-[600px]">
<CampaignCalender
campaigns={calendarCampaigns}
onChangeDate={refreshCalendarCampaings}
bind:start={calendarStartDate}
bind:end={calendarEndDate}
onChangeDate={refreshCalendarCampaings}
showCompany={!contextCompanyID}
/>
</div>
<SubHeadline>Active campaigns</SubHeadline>
<div class="min-h-[300px] mb-8">
<Table
isGhost={isActiveCampaignsLoading}
columns={[
{ column: 'Name', size: 'large' },
{ column: 'Company', size: 'medium' },
{ title: 'Delivery started', column: 'Send start at', size: 'small' },
{ title: 'Delivery finished', column: 'Send end at', size: 'small' }
]}
hasData={!!activeCampaigns.length}
hasNextPage={activeCampaignsHasNextPage}
plural="active campaigns"
pagination={activeTableURLParams}
>
{#each activeCampaigns as campaign}
<TableRow>
<TableCell>
<span class="inline-flex items-center gap-1 py-1">
{#if campaign.isTest}
<TestLabel />
{/if}
<a href={`/campaign/${campaign.id}`}>
{campaign.name}
</a>
</span>
</TableCell>
<TableCell value={campaign.company?.name} />
<TableCell value={campaign.sendStartAt} isDate isRelative />
<TableCell value={campaign.sendEndAt} isDate isRelative />
<TableCellEmpty />
<TableCellAction>
<TableDropDownEllipsis>
<TableViewButton on:click={() => onClickViewCampaign(campaign.id)} />
</TableDropDownEllipsis>
</TableCellAction>
</TableRow>
{/each}
</Table>
</div>
<SubHeadline>Scheduled campaigns</SubHeadline>
<div class="min-h-[300px] mb-8">
<Table
isGhost={isUpcomingCampaignsLoading}
columns={[
{ column: 'Name', size: 'large' },
{ column: 'Company', size: 'medium' },
{ title: 'Delivery started', column: 'Send start at', size: 'small' },
{ title: 'Delivery finished', column: 'Send end at', size: 'small' }
]}
hasData={!!scheduledCampaigns.length}
hasNextPage={scheduledCampaignsHasNextPage}
plural="scheduled campaigns"
pagination={scheduledTableURLParams}
>
{#each scheduledCampaigns as campaign}
<TableRow>
<TableCell>
<span class="inline-flex items-center gap-1 py-1">
{#if campaign.isTest}
<TestLabel />
{/if}
<a href={`/campaign/${campaign.id}`}>
{campaign.name}
</a>
</span>
</TableCell>
<TableCell value={campaign.company?.name} />
<TableCell value={campaign.sendStartAt} isDate isRelative />
<TableCell value={campaign.sendEndAt} isDate isRelative />
<TableCellEmpty />
<TableCellAction>
<TableDropDownEllipsis>
<TableViewButton on:click={() => onClickViewCampaign(campaign.id)} />
</TableDropDownEllipsis>
</TableCellAction>
</TableRow>
{/each}
</Table>
</div>
</main>
@@ -0,0 +1,438 @@
<script>
import Headline from '$lib/components/Headline.svelte';
import HeadTitle from '$lib/components/HeadTitle.svelte';
import SubHeadline from '$lib/components/SubHeadline.svelte';
import { AppStateService } from '$lib/service/appState';
import { api } from '$lib/api/apiProxy.js';
import { onMount } from 'svelte';
import { addToast } from '$lib/store/toast';
import { newTableURLParams } from '$lib/service/tableURLParams';
import Table from '$lib/components/table/Table.svelte';
import TableRow from '$lib/components/table/TableRow.svelte';
import TableCell from '$lib/components/table/TableCell.svelte';
import TableCellEmpty from '$lib/components/table/TableCellEmpty.svelte';
import TableCellAction from '$lib/components/table/TableCellAction.svelte';
import TableViewButton from '$lib/components/table/TableViewButton.svelte';
import { goto } from '$app/navigation';
import TableDropDownEllipsis from '$lib/components/table/TableDropDownEllipsis.svelte';
import TestLabel from '$lib/components/TestLabel.svelte';
import AutoRefresh from '$lib/components/AutoRefresh.svelte';
import { tick } from 'svelte';
import TextFieldSelect from '$lib/components/TextFieldSelect.svelte';
import { autoRefreshStore, setPageAutoRefresh } from '$lib/store/autoRefresh';
import { BiMap } from '$lib/utils/maps';
import DashboardNav from '$lib/components/DashboardNav.svelte';
// services
const appStateService = AppStateService.instance;
// auto-refresh options
const autoRefreshOptions = new BiMap({
Disabled: '0',
'5s': '5000',
'30s': '30000',
'1m': '60000',
'5m': '300000'
});
// local state
let contextCompanyID = null;
let contextCompanyName = '';
let activeTableURLParams = newTableURLParams({
prefix: 'active',
sortBy: 'send_start_at',
sortOrder: 'desc',
noScroll: true
});
let scheduledTableURLParams = newTableURLParams({
prefix: 'scheduled',
sortBy: 'send_start_at',
sortOrder: 'desc',
noScroll: true
});
let completedTableURLParams = newTableURLParams({
prefix: 'completed',
sortBy: 'send_start_at',
sortOrder: 'desc',
noScroll: true
});
let isActiveCampaignsLoading = false;
let isScheduledCampaignsLoading = false;
let isCompletedCampaignsLoading = false;
let activeCampaigns = [];
let activeCampaignsHasNextPage = true;
let scheduledCampaigns = [];
let scheduledCampaignsHasNextPage = true;
let completedCampaigns = [];
let completedCampaignsHasNextPage = true;
let includeTestCampaigns = false;
// handler for when toggle changes
const handleToggleChange = async () => {
await tick();
await refresh();
};
const handleAutoRefreshChange = (optKey) => {
const value = Number(autoRefreshOptions.byKey(optKey));
autoRefreshStore.setEnabled(value > 0);
autoRefreshStore.setInterval(value);
setPageAutoRefresh('dashboard-campaigns', $autoRefreshStore);
};
// hooks
onMount(() => {
const context = appStateService.getContext();
if (context) {
contextCompanyID = context.companyID;
contextCompanyName = context.companyName;
}
refresh();
activeTableURLParams.onChange(() => refreshActiveCampaigns(true));
scheduledTableURLParams.onChange(() => refreshScheduledCampaigns(true));
completedTableURLParams.onChange(() => refreshCompletedCampaigns(true));
return () => {
activeTableURLParams.unsubscribe();
scheduledTableURLParams.unsubscribe();
completedTableURLParams.unsubscribe();
};
});
const refresh = async () => {
await Promise.all([
refreshActiveCampaigns(false),
refreshScheduledCampaigns(false),
refreshCompletedCampaigns(false)
]);
};
const refreshActiveCampaigns = async (showLoading = true) => {
if (showLoading) {
isActiveCampaignsLoading = true;
}
try {
const options = {
page: activeTableURLParams.currentPage,
perPage: activeTableURLParams.perPage,
sortBy: activeTableURLParams.sortBy,
sortOrder: activeTableURLParams.sortOrder,
search: activeTableURLParams.search,
includeTest: includeTestCampaigns
};
const res = await api.campaign.getAllActive(options, contextCompanyID);
if (res.success) {
activeCampaigns = res.data.rows;
activeCampaignsHasNextPage = res.data.hasNextPage;
}
} catch (e) {
addToast('Failed to load active campaigns', 'Error');
console.error('failed to load active campaigns', e);
} finally {
if (showLoading) {
isActiveCampaignsLoading = false;
}
}
};
const refreshScheduledCampaigns = async (showLoading = true) => {
if (showLoading) {
isScheduledCampaignsLoading = true;
}
try {
const options = {
page: scheduledTableURLParams.currentPage,
perPage: scheduledTableURLParams.perPage,
sortBy: scheduledTableURLParams.sortBy,
sortOrder: scheduledTableURLParams.sortOrder,
search: scheduledTableURLParams.search,
includeTest: includeTestCampaigns
};
const res = await api.campaign.getAllUpcoming(options, contextCompanyID);
if (res.success) {
scheduledCampaigns = res.data.rows;
scheduledCampaignsHasNextPage = res.data.hasNextPage;
}
} catch (e) {
addToast('Failed to load scheduled campaigns', 'Error');
console.error('failed to load scheduled campaigns', e);
} finally {
if (showLoading) {
isScheduledCampaignsLoading = false;
}
}
};
const refreshCompletedCampaigns = async (showLoading = true) => {
if (showLoading) {
isCompletedCampaignsLoading = true;
}
try {
const options = {
page: completedTableURLParams.currentPage,
perPage: completedTableURLParams.perPage,
sortBy: completedTableURLParams.sortBy,
sortOrder: completedTableURLParams.sortOrder,
search: completedTableURLParams.search,
includeTest: includeTestCampaigns
};
const res = await api.campaign.getAllFinished(options, contextCompanyID);
if (res.success) {
completedCampaigns = res.data.rows;
completedCampaignsHasNextPage = res.data.hasNextPage;
}
} catch (e) {
addToast('Failed to load completed campaigns', 'Error');
console.error('failed to load completed campaigns', e);
} finally {
if (showLoading) {
isCompletedCampaignsLoading = false;
}
}
};
const onClickViewCampaign = (id) => {
goto(`/campaign/${id}`);
};
</script>
<HeadTitle title="Dashboard - Campaigns" />
<main>
<Headline>Dashboard</Headline>
<DashboardNav />
<div class="flex justify-between items-center mb-6">
<SubHeadline>Campaigns</SubHeadline>
<div class="flex items-center gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<span class="font-semibold text-slate-600 dark:text-gray-300 whitespace-nowrap">
Include test campaigns
</span>
<div class="relative flex items-center">
<input
type="checkbox"
id="includeTestCampaigns"
bind:checked={includeTestCampaigns}
on:change={handleToggleChange}
class="peer sr-only"
/>
<div
class="w-5 h-5 border-2 border-slate-300 dark:border-gray-700/60 rounded
peer-checked:border-cta-blue dark:peer-checked:border-highlight-blue/80 peer-checked:bg-cta-blue dark:peer-checked:bg-highlight-blue/80
peer-focus:border-slate-400 dark:peer-focus:border-highlight-blue/80 peer-focus:bg-gray-100 dark:peer-focus:bg-gray-700/60
transition-all duration-200 ease-in-out
flex items-center justify-center
bg-slate-50 dark:bg-gray-900/60"
>
{#if includeTestCampaigns}
<svg class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="3"
d="M5 13l4 4L19 7"
/>
</svg>
{/if}
</div>
</div>
</label>
<div class="flex items-center gap-2">
<span class="font-semibold text-slate-600 dark:text-gray-300 whitespace-nowrap">
Auto-Refresh
</span>
<TextFieldSelect
id="autoRefresh"
value={$autoRefreshStore.enabled
? autoRefreshOptions.byValue($autoRefreshStore.interval.toString())
: 'Disabled'}
onSelect={handleAutoRefreshChange}
options={autoRefreshOptions.keys()}
inline={true}
size={'small'}
/>
</div>
</div>
</div>
<AutoRefresh
isLoading={false}
pageId="dashboard-campaigns"
onRefresh={async () => {
const activeOptions = {
page: activeTableURLParams.currentPage,
perPage: activeTableURLParams.perPage,
sortBy: activeTableURLParams.sortBy,
sortOrder: activeTableURLParams.sortOrder,
search: activeTableURLParams.search,
includeTest: includeTestCampaigns
};
const activeRes = await api.campaign.getAllActive(activeOptions, contextCompanyID);
if (activeRes.success) {
activeCampaigns = activeRes.data.rows;
activeCampaignsHasNextPage = activeRes.data.hasNextPage;
}
const scheduledOptions = {
page: scheduledTableURLParams.currentPage,
perPage: scheduledTableURLParams.perPage,
sortBy: scheduledTableURLParams.sortBy,
sortOrder: scheduledTableURLParams.sortOrder,
search: scheduledTableURLParams.search,
includeTest: includeTestCampaigns
};
const scheduledRes = await api.campaign.getAllUpcoming(scheduledOptions, contextCompanyID);
if (scheduledRes.success) {
scheduledCampaigns = scheduledRes.data.rows;
scheduledCampaignsHasNextPage = scheduledRes.data.hasNextPage;
}
const completedOptions = {
page: completedTableURLParams.currentPage,
perPage: completedTableURLParams.perPage,
sortBy: completedTableURLParams.sortBy,
sortOrder: completedTableURLParams.sortOrder,
search: completedTableURLParams.search,
includeTest: includeTestCampaigns
};
const completedRes = await api.campaign.getAllFinished(completedOptions, contextCompanyID);
if (completedRes.success) {
completedCampaigns = completedRes.data.rows;
completedCampaignsHasNextPage = completedRes.data.hasNextPage;
}
}}
/>
<SubHeadline>Active campaigns</SubHeadline>
<div class="min-h-[300px] mb-8">
<Table
isGhost={isActiveCampaignsLoading}
columns={[
{ column: 'Name', size: 'large' },
...(contextCompanyID ? [] : [{ column: 'Company', size: 'medium' }]),
{ title: 'Delivery started', column: 'Send start at', size: 'small' },
{ title: 'Delivery finishes', column: 'Send end at', size: 'small' }
]}
hasData={!!activeCampaigns.length}
hasNextPage={activeCampaignsHasNextPage}
plural="active campaigns"
pagination={activeTableURLParams}
>
{#each activeCampaigns as campaign}
<TableRow>
<TableCell>
<span class="inline-flex items-center gap-1 py-1">
{#if campaign.isTest}
<TestLabel />
{/if}
<a href={`/campaign/${campaign.id}`}>
{campaign.name}
</a>
</span>
</TableCell>
{#if !contextCompanyID}
<TableCell value={campaign.company?.name} />
{/if}
<TableCell value={campaign.sendStartAt} isDate isRelative />
<TableCell value={campaign.sendEndAt} isDate isRelative />
<TableCellEmpty />
<TableCellAction>
<TableDropDownEllipsis>
<TableViewButton on:click={() => onClickViewCampaign(campaign.id)} />
</TableDropDownEllipsis>
</TableCellAction>
</TableRow>
{/each}
</Table>
</div>
<SubHeadline>Scheduled campaigns</SubHeadline>
<div class="min-h-[300px] mb-8">
<Table
isGhost={isScheduledCampaignsLoading}
columns={[
{ column: 'Name', size: 'large' },
...(contextCompanyID ? [] : [{ column: 'Company', size: 'medium' }]),
{ title: 'Delivery starts', column: 'Send start at', size: 'small' },
{ title: 'Delivery finishes', column: 'Send end at', size: 'small' }
]}
hasData={!!scheduledCampaigns.length}
hasNextPage={scheduledCampaignsHasNextPage}
plural="scheduled campaigns"
pagination={scheduledTableURLParams}
>
{#each scheduledCampaigns as campaign}
<TableRow>
<TableCell>
<span class="inline-flex items-center gap-1 py-1">
{#if campaign.isTest}
<TestLabel />
{/if}
<a href={`/campaign/${campaign.id}`}>
{campaign.name}
</a>
</span>
</TableCell>
{#if !contextCompanyID}
<TableCell value={campaign.company?.name} />
{/if}
<TableCell value={campaign.sendStartAt} isDate isRelative />
<TableCell value={campaign.sendEndAt} isDate isRelative />
<TableCellEmpty />
<TableCellAction>
<TableDropDownEllipsis>
<TableViewButton on:click={() => onClickViewCampaign(campaign.id)} />
</TableDropDownEllipsis>
</TableCellAction>
</TableRow>
{/each}
</Table>
</div>
<SubHeadline>Completed campaigns</SubHeadline>
<div class="min-h-[300px] mb-8">
<Table
isGhost={isCompletedCampaignsLoading}
columns={[
{ column: 'Name', size: 'large' },
...(contextCompanyID ? [] : [{ column: 'Company', size: 'medium' }]),
{ title: 'Delivery started', column: 'Send start at', size: 'small' },
{ title: 'Delivery finished', column: 'Send end at', size: 'small' }
]}
hasData={!!completedCampaigns.length}
hasNextPage={completedCampaignsHasNextPage}
plural="completed campaigns"
pagination={completedTableURLParams}
>
{#each completedCampaigns as campaign}
<TableRow>
<TableCell>
<span class="inline-flex items-center gap-1 py-1">
{#if campaign.isTest}
<TestLabel />
{/if}
<a href={`/campaign/${campaign.id}`}>
{campaign.name}
</a>
</span>
</TableCell>
{#if !contextCompanyID}
<TableCell value={campaign.company?.name} />
{/if}
<TableCell value={campaign.sendStartAt} isDate isRelative />
<TableCell value={campaign.sendEndAt} isDate isRelative />
<TableCellEmpty />
<TableCellAction>
<TableDropDownEllipsis>
<TableViewButton on:click={() => onClickViewCampaign(campaign.id)} />
</TableDropDownEllipsis>
</TableCellAction>
</TableRow>
{/each}
</Table>
</div>
</main>
@@ -0,0 +1,252 @@
<script>
import Headline from '$lib/components/Headline.svelte';
import HeadTitle from '$lib/components/HeadTitle.svelte';
import SubHeadline from '$lib/components/SubHeadline.svelte';
import { AppStateService } from '$lib/service/appState';
import { api } from '$lib/api/apiProxy.js';
import { onMount } from 'svelte';
import { addToast } from '$lib/store/toast';
import { newTableURLParams } from '$lib/service/tableURLParams';
import Table from '$lib/components/table/Table.svelte';
import TableRow from '$lib/components/table/TableRow.svelte';
import TableCell from '$lib/components/table/TableCell.svelte';
import AutoRefresh from '$lib/components/AutoRefresh.svelte';
import { tick } from 'svelte';
import EventName from '$lib/components/table/EventName.svelte';
import TextFieldSelect from '$lib/components/TextFieldSelect.svelte';
import { autoRefreshStore, setPageAutoRefresh } from '$lib/store/autoRefresh';
import { BiMap } from '$lib/utils/maps';
import DashboardNav from '$lib/components/DashboardNav.svelte';
// services
const appStateService = AppStateService.instance;
// auto-refresh options
const autoRefreshOptions = new BiMap({
Disabled: '0',
'5s': '5000',
'30s': '30000',
'1m': '60000',
'5m': '300000'
});
// local state
let contextCompanyID = null;
let contextCompanyName = '';
let eventsTableURLParams = newTableURLParams({
prefix: 'events',
sortBy: 'created_at',
sortOrder: 'desc',
noScroll: true
});
let isEventsLoading = false;
let events = [];
let eventsHasNextPage = true;
let eventTypesIDToNameMap = {};
let includeTestCampaigns = false;
// handler for when toggle changes
const handleToggleChange = async () => {
await tick();
await refreshEvents(true);
};
const handleAutoRefreshChange = (optKey) => {
const value = Number(autoRefreshOptions.byKey(optKey));
autoRefreshStore.setEnabled(value > 0);
autoRefreshStore.setInterval(value);
setPageAutoRefresh('dashboard-events', $autoRefreshStore);
};
// hooks
onMount(() => {
const context = appStateService.getContext();
if (context) {
contextCompanyID = context.companyID;
contextCompanyName = context.companyName;
}
setEventTypes();
refreshEvents(true);
eventsTableURLParams.onChange(() => refreshEvents(true));
return () => {
eventsTableURLParams.unsubscribe();
};
});
const setEventTypes = async () => {
try {
const res = await api.campaign.getAllEventTypes();
if (!res.success) {
addToast('Failed to load event types', 'Error');
console.error('failed to load event types', res.error);
return;
}
res.data.map((t) => (eventTypesIDToNameMap[t.id] = t.name));
} catch (e) {
addToast('Failed to load event types', 'Error');
console.error('failed to load event types', e);
}
};
const refreshEvents = async (showIsLoading = true) => {
try {
if (showIsLoading) {
isEventsLoading = true;
}
const options = {
page: eventsTableURLParams.page,
perPage: eventsTableURLParams.perPage,
sortBy: eventsTableURLParams.sortBy,
sortOrder: eventsTableURLParams.sortOrder,
search: eventsTableURLParams.search,
includeTest: includeTestCampaigns
};
const res = await api.campaign.getAllEvents(options, contextCompanyID);
if (res.success) {
events = res.data?.rows ?? [];
eventsHasNextPage = res.data?.hasNextPage ?? false;
}
} catch (e) {
addToast('Failed to load events', 'Error');
console.error('failed to load events', e);
} finally {
if (showIsLoading) {
isEventsLoading = false;
}
}
};
</script>
<HeadTitle title="Dashboard - Events" />
<main>
<Headline>Dashboard</Headline>
<DashboardNav />
<div class="flex justify-between items-center mb-6">
<SubHeadline>Recent Events</SubHeadline>
<div class="flex items-center gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<span class="font-semibold text-slate-600 dark:text-gray-300 whitespace-nowrap">
Include test campaigns
</span>
<div class="relative flex items-center">
<input
type="checkbox"
id="includeTestCampaigns"
bind:checked={includeTestCampaigns}
on:change={handleToggleChange}
class="peer sr-only"
/>
<div
class="w-5 h-5 border-2 border-slate-300 dark:border-gray-700/60 rounded
peer-checked:border-cta-blue dark:peer-checked:border-highlight-blue/80 peer-checked:bg-cta-blue dark:peer-checked:bg-highlight-blue/80
peer-focus:border-slate-400 dark:peer-focus:border-highlight-blue/80 peer-focus:bg-gray-100 dark:peer-focus:bg-gray-700/60
transition-all duration-200 ease-in-out
flex items-center justify-center
bg-slate-50 dark:bg-gray-900/60"
>
{#if includeTestCampaigns}
<svg class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="3"
d="M5 13l4 4L19 7"
/>
</svg>
{/if}
</div>
</div>
</label>
<div class="flex items-center gap-2">
<span class="font-semibold text-slate-600 dark:text-gray-300 whitespace-nowrap">
Auto-Refresh
</span>
<TextFieldSelect
id="autoRefresh"
value={$autoRefreshStore.enabled
? autoRefreshOptions.byValue($autoRefreshStore.interval.toString())
: 'Disabled'}
onSelect={handleAutoRefreshChange}
options={autoRefreshOptions.keys()}
inline={true}
size={'small'}
/>
</div>
</div>
</div>
<AutoRefresh
isLoading={false}
pageId="dashboard-events"
onRefresh={async () => {
const eventsOptions = {
page: eventsTableURLParams.currentPage,
perPage: eventsTableURLParams.perPage,
sortBy: eventsTableURLParams.sortBy,
sortOrder: eventsTableURLParams.sortOrder,
search: eventsTableURLParams.search,
includeTest: includeTestCampaigns
};
const eventsRes = await api.campaign.getAllEvents(eventsOptions, contextCompanyID);
if (eventsRes.success) {
events = eventsRes.data?.rows ?? [];
eventsHasNextPage = eventsRes.data?.hasNextPage ?? false;
}
}}
/>
<div class="min-h-[300px] mb-8">
<Table
columns={[
{ column: 'Time', size: 'large' },
{ column: 'Event', size: 'large' },
{ column: 'Campaign', size: 'large' },
{ column: 'Email', size: 'large' },
...(contextCompanyID ? [] : [{ column: 'Company', size: 'large' }])
]}
pagination={eventsTableURLParams}
plural="events"
hasData={!!events.length}
hasNextPage={eventsHasNextPage}
isGhost={isEventsLoading}
noSearch={true}
hasActions={false}
>
{#each events as event (event.id)}
<TableRow>
<TableCell isDate isRelative value={event.createdAt} />
<TableCell>
<EventName eventName={eventTypesIDToNameMap[event.eventID]} />
</TableCell>
<TableCell>
{#if event.campaign?.name}
<a href={`/campaign/${event.campaignID}`} class="block w-full py-1">
{event.campaign.name}
</a>
{/if}
</TableCell>
<TableCell>
{#if event.recipient?.email}
<a href={`/recipient/${event.recipient.id}`} class="block w-full py-1">
{event.recipient.email}
</a>
{/if}
</TableCell>
{#if !contextCompanyID}
<TableCell>
{#if event.campaign?.company?.name}
{event.campaign.company.name}
{/if}
</TableCell>
{/if}
</TableRow>
{/each}
</Table>
</div>
</main>