add latest events to dashboard, remove completed, fix missing auto refresh box

This commit is contained in:
Ronni Skansing
2026-02-12 20:58:08 +01:00
parent 0d580c1456
commit 2f5d1163d2
9 changed files with 437 additions and 161 deletions
+2
View File
@@ -143,6 +143,7 @@ const (
ROUTE_V1_CAMPAIGN_RECIPIENTS = "/api/v1/campaign/:id/recipients"
ROUTE_V1_CAMPAIGN_RESULT_STATS = "/api/v1/campaign/:id/statistics"
ROUTE_V1_CAMPAIGN_EVENTS = "/api/v1/campaign/:id/events"
ROUTE_V1_CAMPAIGN_ALL_EVENTS = "/api/v1/campaign/events"
ROUTE_V1_CAMPAIGN_EVENT_ID = "/api/v1/campaign/event/:id"
ROUTE_V1_CAMPAIGN_EVENT_NAMES = "/api/v1/campaign/event-types"
ROUTE_V1_CAMPAIGN_STATS = "/api/v1/campaign/statistics"
@@ -409,6 +410,7 @@ func setupRoutes(
GET(ROUTE_V1_CAMPAIGN_UPCOMING, middleware.SessionHandler, controllers.Campaign.GetAllUpcoming).
GET(ROUTE_V1_CAMPAIGN_FINISHED, middleware.SessionHandler, controllers.Campaign.GetAllFinished).
GET(ROUTE_V1_CAMPAIGN_EVENT_NAMES, middleware.SessionHandler, controllers.Campaign.GetAllEventTypes).
GET(ROUTE_V1_CAMPAIGN_ALL_EVENTS, middleware.SessionHandler, controllers.Campaign.GetAllEvents).
GET(ROUTE_V1_CAMPAIGN_EVENTS, middleware.SessionHandler, controllers.Campaign.GetEventsByCampaignID).
DELETE(ROUTE_V1_CAMPAIGN_EVENT_ID, middleware.SessionHandler, controllers.Campaign.DeleteEventByID).
GET(ROUTE_V1_CAMPAIGN_STATS, middleware.SessionHandler, controllers.Campaign.GetStats).
+33
View File
@@ -468,6 +468,39 @@ func (c *Campaign) GetAllFinished(g *gin.Context) {
c.Response.OK(g, campaigns)
}
// GetAllEvents gets all events across campaigns
func (c *Campaign) GetAllEvents(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
includeTestCampaigns := g.Query("includeTest") == "true"
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
// force ordering by created_at desc for performance (no sorting allowed)
queryArgs.OrderBy = repository.TableColumn(database.CAMPAIGN_EVENT_TABLE, "created_at")
queryArgs.Desc = true
// disable search for performance
queryArgs.Search = ""
// get events
events, err := c.CampaignService.GetAllEvents(
g.Request.Context(),
session,
companyID,
queryArgs,
includeTestCampaigns,
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, events)
}
// GetEventsByCampaignID gets events by campaign id
func (c *Campaign) GetEventsByCampaignID(g *gin.Context) {
session, _, ok := c.handleSession(g)
+1
View File
@@ -38,6 +38,7 @@ type CampaignEvent struct {
RecipientID *uuid.UUID `gorm:"index;type:uuid;"`
Recipient *Recipient
Campaign *Campaign
CompanyID *uuid.UUID `gorm:"type:uuid;"`
}
+1
View File
@@ -11,6 +11,7 @@ type CampaignEvent struct {
ID *uuid.UUID `json:"id"`
CreatedAt *time.Time `json:"createdAt"`
CampaignID *uuid.UUID `json:"campaignID"`
Campaign *Campaign `json:"campaign,omitempty"`
IP *vo.OptionalString64 `json:"ip"`
UserAgent *vo.OptionalString255 `json:"userAgent"`
Data *vo.OptionalString1MB `json:"data"`
+101 -1
View File
@@ -66,11 +66,19 @@ type CampaignOption struct {
// CampaignEventOption is options for preloading
type CampaignEventOption struct {
*vo.QueryArgs
// WithCampaign bool
WithCampaign bool
WithUser bool
EventTypeIDs []string
}
// CampaignEventAllOption is options for preloading events across all campaigns
type CampaignEventAllOption struct {
*vo.QueryArgs
WithCampaign bool
WithUser bool
IncludeTestCampaigns bool
}
// Campaign is a Campaign repository
type Campaign struct {
DB *gorm.DB
@@ -114,11 +122,29 @@ func (r *Campaign) load(db *gorm.DB, options *CampaignOption) *gorm.DB {
// preloadEventRecipient preloads the event user
func (r *Campaign) preloadEventRecipient(db *gorm.DB, options *CampaignEventOption) *gorm.DB {
if options.WithUser {
db = db.Preload("Recipient", func(db *gorm.DB) *gorm.DB {
return db.Select("id", "first_name", "last_name", "email")
})
}
if options.WithCampaign {
db = db.Preload("Campaign", func(db *gorm.DB) *gorm.DB {
return db.Select("id", "name", "company_id").Preload("Company")
})
}
return db
}
// preloadAllEventRecipient preloads the event user for all events queries
func (r *Campaign) preloadAllEventRecipient(db *gorm.DB, options *CampaignEventAllOption) *gorm.DB {
if options.WithUser {
db = db.Preload("Recipient", func(db *gorm.DB) *gorm.DB {
return db
})
}
if options.WithCampaign {
db = db.Preload("Campaign.Company")
}
return db
}
@@ -583,6 +609,71 @@ func (r *Campaign) GetAllFinished(
return result, nil
}
// GetAllEvents gets all campaign events across campaigns
func (r *Campaign) GetAllEvents(
ctx context.Context,
companyID *uuid.UUID,
options *CampaignEventAllOption,
) (*model.Result[model.CampaignEvent], error) {
result := model.NewEmptyResult[model.CampaignEvent]()
db := r.preloadAllEventRecipient(r.DB, options)
db = r.joinEvent(db)
db, err := useQuery(db, database.CAMPAIGN_TABLE, options.QueryArgs, allowedCampaginEventViewColumns...)
if err != nil {
return result, errs.Wrap(err)
}
var dbCampaignEvents []database.CampaignEvent
db = db.
Joins(LeftJoinOn(
database.CAMPAIGN_EVENT_TABLE,
"recipient_id",
database.RECIPIENT_TABLE,
"id",
)).
Joins(LeftJoinOn(
database.CAMPAIGN_EVENT_TABLE,
"campaign_id",
database.CAMPAIGN_TABLE,
"id",
))
// filter by company if in company context
if companyID != nil {
db = db.Where(TableColumn(database.CAMPAIGN_TABLE, "company_id")+" = ?", companyID)
}
// filter test campaigns if not included
if !options.IncludeTestCampaigns {
db = db.Where(TableColumn(database.CAMPAIGN_TABLE, "is_test")+" = ?", false)
}
res := db.Find(&dbCampaignEvents)
if res.Error != nil {
return result, res.Error
}
hasNextPage, err := useHasNextPage(
db,
database.CAMPAIGN_TABLE,
options.QueryArgs,
allowedCampaginEventViewColumns...,
)
if err != nil {
return result, errs.Wrap(err)
}
result.HasNextPage = hasNextPage
for _, dbCampaignEvent := range dbCampaignEvents {
c, err := ToCampaignEvent(&dbCampaignEvent)
if err != nil {
return nil, errs.Wrap(err)
}
result.Rows = append(result.Rows, c)
}
return result, nil
}
// GetEventsByCampaignID gets all campaign events by campaign id
func (r *Campaign) GetEventsByCampaignID(
ctx context.Context,
@@ -1720,6 +1811,14 @@ func ToCampaignEvent(row *database.CampaignEvent) (*model.CampaignEvent, error)
}
recipient = r
}
var campaign *model.Campaign
if row.Campaign != nil {
c, err := ToCampaign(row.Campaign)
if err != nil {
return nil, errs.Wrap(err)
}
campaign = c
}
ip := vo.NewOptionalString64Must(row.IPAddress)
userAgent := vo.NewOptionalString255Must(row.UserAgent)
data := vo.NewOptionalString1MBMust(row.Data)
@@ -1729,6 +1828,7 @@ func ToCampaignEvent(row *database.CampaignEvent) (*model.CampaignEvent, error)
ID: row.ID,
CreatedAt: row.CreatedAt,
CampaignID: row.CampaignID,
Campaign: campaign,
IP: ip,
UserAgent: userAgent,
Data: data,
+38
View File
@@ -912,6 +912,44 @@ func (c *Campaign) GetRecipientsByCampaignID(
return result, nil
}
// GetAllEvents gets all events across campaigns
func (c *Campaign) GetAllEvents(
ctx context.Context,
session *model.Session,
companyID *uuid.UUID,
queryArgs *vo.QueryArgs,
includeTestCampaigns bool,
) (*model.Result[model.CampaignEvent], error) {
result := model.NewEmptyResult[model.CampaignEvent]()
ae := NewAuditEvent("Campaign.GetAllEvents", session)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
c.LogAuthError(err)
return result, errs.Wrap(err)
}
if !isAuthorized {
c.AuditLogNotAuthorized(ae)
return result, errs.ErrAuthorizationFailed
}
result, err = c.CampaignRepository.GetAllEvents(
ctx,
companyID,
&repository.CampaignEventAllOption{
QueryArgs: queryArgs,
WithUser: true,
WithCampaign: true,
IncludeTestCampaigns: includeTestCampaigns,
},
)
if err != nil {
c.Logger.Errorw("failed to get all events", "error", err)
return result, errs.Wrap(err)
}
// no audit on read
return result, nil
}
// GetEventsByCampaignID gets all events for a campaign
func (c *Campaign) GetEventsByCampaignID(
ctx context.Context,
+15
View File
@@ -807,6 +807,21 @@ export class API {
);
},
/**
* Get all events across campaigns.
*
* @param {TableURLParams} options
* @param {string|null} companyID
* @returns {Promise<ApiResponse>}
*/
getAllEvents: async (options, companyID = null) => {
return await getJSON(
this.getPath(
`/campaign/events?${appendQuery(options)}${this.appendCompanyQuery(companyID)}`
)
);
},
/**
* Get campaigns stats
* if no company ID is provided it retrieves the global stats including all companies
@@ -26,6 +26,7 @@
export let isGhost = false;
// if there is more data to paginate
export let hasNextPage = true;
export let noSearch = false;
let tableWrapper = null;
let columnsLength = columns.length;
@@ -52,7 +53,9 @@
<div class="flex justify-between items-center pb-4">
{#if pagination}
<Select {pagination}></Select>
<Search {pagination}></Search>
{#if !noSearch}
<Search {pagination}></Search>
{/if}
{/if}
</div>
<div
+242 -159
View File
@@ -24,19 +24,26 @@
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';
// 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 completedTableURLParams = newTableURLParams({
prefix: 'completed',
sortBy: 'send_start_at',
sortOrder: 'desc',
noScroll: true
});
let scheduledTableURLParams = newTableURLParams({
prefix: 'scheduled',
sortBy: 'send_start_at',
@@ -49,10 +56,16 @@
sortOrder: 'desc',
noScroll: true
});
let eventsTableURLParams = newTableURLParams({
prefix: 'events',
sortBy: 'created_at',
sortOrder: 'desc',
noScroll: true
});
let isActiveCampaignsLoading = false;
let isUpcomingCampaignsLoading = false;
let isFinishedCampaignsLoading = false;
let isEventsLoading = false;
let active = 0;
let scheduled = 0;
@@ -65,8 +78,11 @@
let activeCampaignsHasNextPage = true;
let scheduledCampaigns = [];
let scheduledCampaignsHasNextPage = true;
let completedCampaigns = [];
let completedCampaignsHasNextPage = true;
let events = [];
let eventsHasNextPage = true;
let eventTypesIDToNameMap = {};
let availableEventTypes = [];
let selectedEventType = '';
let campaignStats = [];
let isCampaignStatsLoading = false;
@@ -86,6 +102,13 @@
await refresh(false);
};
const handleAutoRefreshChange = (optKey) => {
const value = Number(autoRefreshOptions.byKey(optKey));
autoRefreshStore.setEnabled(value > 0);
autoRefreshStore.setInterval(value);
setPageAutoRefresh('dashboard', $autoRefreshStore);
};
// hooks
onMount(() => {
const context = appStateService.getContext();
@@ -96,12 +119,12 @@
refresh();
activeTableURLParams.onChange(() => refreshActiveCampaigns(true));
scheduledTableURLParams.onChange(() => refreshScheduledCampaigns(true));
completedTableURLParams.onChange(() => refreshFinishedCampaigns(true));
eventsTableURLParams.onChange(() => refreshEvents(true));
return () => {
activeTableURLParams.unsubscribe();
scheduledTableURLParams.unsubscribe();
completedTableURLParams.unsubscribe();
eventsTableURLParams.unsubscribe();
};
});
@@ -121,10 +144,11 @@
active = res.data.active;
scheduled = res.data.upcoming;
finished = res.data.finished;
await setEventTypes();
await refreshCalendarCampaings();
await refreshActiveCampaigns(showLoading);
await refreshScheduledCampaigns(showLoading);
await refreshFinishedCampaigns(showLoading);
await refreshEvents(showLoading);
await refreshCampaignStats(showLoading);
} catch (e) {
addToast('Failed to load data', 'Error');
@@ -216,31 +240,47 @@
}
};
const refreshFinishedCampaigns = async (showLoading = true) => {
if (showLoading) {
isFinishedCampaignsLoading = true;
}
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: completedTableURLParams.currentPage,
perPage: completedTableURLParams.perPage,
sortBy: completedTableURLParams.sortBy,
sortOrder: completedTableURLParams.sortOrder,
search: completedTableURLParams.search,
page: eventsTableURLParams.page,
perPage: eventsTableURLParams.perPage,
sortBy: eventsTableURLParams.sortBy,
sortOrder: eventsTableURLParams.sortOrder,
search: eventsTableURLParams.search,
includeTest: includeTestCampaigns
};
const res = await api.campaign.getAllFinished(options, contextCompanyID);
if (!res.success) {
throw res.error;
const res = await api.campaign.getAllEvents(options, contextCompanyID);
if (res.success) {
events = res.data?.rows ?? [];
eventsHasNextPage = res.data?.hasNextPage ?? false;
}
completedCampaigns = res.data.rows;
completedCampaignsHasNextPage = res.data.hasNextPage;
} catch (e) {
addToast('Failed to load finshed campaigns', 'Error');
console.error('Failed to load finshed campaigns', e);
addToast('Failed to load events', 'Error');
console.error('failed to load events', e);
} finally {
if (showLoading) {
isFinishedCampaignsLoading = false;
if (showIsLoading) {
isEventsLoading = false;
}
}
};
@@ -288,98 +328,131 @@
<HeadTitle title="Dashboard" />
<main>
<div class="flex justify-between">
<div class="flex justify-between items-center mb-6">
<Headline>Dashboard</Headline>
<div class="flex gap-4 items-center">
<CheckboxField
bind:value={includeTestCampaigns}
on:change={handleToggleChange}
id="includeTestCampaigns"
inline={true}
>
Include test campaigns
</CheckboxField>
<AutoRefresh
isLoading={false}
onRefresh={async () => {
// refresh all data
let res = await api.campaign.getStats(contextCompanyID, {
includeTest: includeTestCampaigns
});
if (!res.success) {
throw res.error;
}
await refreshRepeatOffenders();
active = res.data.active;
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 = [];
await tick();
activeCampaigns = activeRes.data.rows;
}
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 = [];
await tick();
scheduledCampaigns = scheduledRes.data.rows;
}
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 = [];
await tick();
completedCampaigns = completedRes.data.rows;
}
const statsRes = await api.campaign.getAllCampaignStats(contextCompanyID);
if (statsRes.success) {
campaignStats = [];
await tick();
campaignStats = statsRes.data.rows || [];
}
await refreshCalendarCampaings();
}}
/>
<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"
onRefresh={async () => {
// refresh all data
let res = await api.campaign.getStats(contextCompanyID, {
includeTest: includeTestCampaigns
});
if (!res.success) {
throw res.error;
}
await refreshRepeatOffenders();
active = res.data.active;
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 = [];
await tick();
campaignStats = statsRes.data.rows || [];
}
await refreshCalendarCampaings();
}}
/>
{#if contextCompanyName}
<SubHeadline>{contextCompanyName}</SubHeadline>
{/if}
@@ -491,6 +564,56 @@
<CampaignTrendChart {campaignStats} isLoading={isCampaignStatsLoading} />
</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
@@ -583,44 +706,4 @@
{/each}
</Table>
</div>
<SubHeadline>Completed campaigns</SubHeadline>
<div class="min-h-[300px] mb-8">
<Table
isGhost={isFinishedCampaignsLoading}
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={!!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>
<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>