add company page - improve company settings UI

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2026-06-12 09:51:54 +02:00
parent 6d025d2dc7
commit d14fc1d396
5 changed files with 891 additions and 913 deletions
@@ -0,0 +1,385 @@
<script>
import { onMount } from 'svelte';
import { api } from '$lib/api/apiProxy.js';
import TableRow from '$lib/components/table/TableRow.svelte';
import TableCell from '$lib/components/table/TableCell.svelte';
import TableUpdateButton from '$lib/components/table/TableUpdateButton.svelte';
import TableDeleteButton from '$lib/components/table/TableDeleteButton2.svelte';
import FormError from '$lib/components/FormError.svelte';
import { addToast } from '$lib/store/toast';
import TableCellAction from '$lib/components/table/TableCellAction.svelte';
import Modal from '$lib/components/Modal.svelte';
import BigButton from '$lib/components/BigButton.svelte';
import FormFooter from '$lib/components/FormFooter.svelte';
import Table from '$lib/components/table/Table.svelte';
import { showIsLoading, hideIsLoading } from '$lib/store/loading.js';
import TableDropDownEllipsis from '$lib/components/table/TableDropDownEllipsis.svelte';
import DeleteAlert from '$lib/components/modal/DeleteAlert.svelte';
import TableCellEmpty from '$lib/components/table/TableCellEmpty.svelte';
import TextField from '$lib/components/TextField.svelte';
import FormGrid from '$lib/components/FormGrid.svelte';
import FormColumns from '$lib/components/FormColumns.svelte';
import FormColumn from '$lib/components/FormColumn.svelte';
// external
export let companyId;
function getStatPercentages(stats) {
const totalRecipients = stats.totalRecipients || 0;
const emailsSent = stats.emailsSent || 0;
const read = stats.trackingPixelLoaded || 0;
const clicked = stats.websiteVisits || 0;
const submitted = stats.dataSubmissions || 0;
const reported = stats.reported || 0;
function pct(n, d) {
return d > 0 ? Math.round((n / d) * 100) : 0;
}
return {
sent: {
count: emailsSent,
absolute: pct(emailsSent, totalRecipients),
relative: pct(emailsSent, totalRecipients)
},
read: {
count: read,
absolute: pct(read, totalRecipients),
relative: pct(read, emailsSent)
},
clicked: {
count: clicked,
absolute: pct(clicked, totalRecipients),
relative: pct(clicked, read)
},
submitted: {
count: submitted,
absolute: pct(submitted, totalRecipients),
relative: pct(submitted, clicked)
},
reported: {
count: reported,
absolute: pct(reported, totalRecipients),
relative: pct(reported, emailsSent)
}
};
}
// bindings
let form = null;
const formValues = {
id: null,
campaignName: '',
totalRecipients: '',
emailsSent: '',
trackingPixelLoaded: '',
websiteVisits: '',
dataSubmissions: '',
reported: '',
date: ''
};
// data
let modalError = '';
let customStats = [];
let isModalVisible = false;
let isSubmitting = false;
let isTableLoading = true;
let modalMode = null;
let modalText = '';
let isDeleteAlertVisible = false;
let deleteValues = {
id: null,
name: null
};
$: {
modalText = modalMode === 'create' ? 'New Custom Stats' : 'Update Custom Stats';
}
onMount(() => {
refreshCustomStats();
});
const refreshCustomStats = async () => {
try {
isTableLoading = true;
customStats = await getCustomStats();
} catch (e) {
addToast('Failed to get custom stats', 'Error');
console.error('failed to get custom stats', e);
} finally {
isTableLoading = false;
}
};
const getCustomStats = async () => {
try {
const res = await api.campaign.getManualCampaignStats(companyId);
if (res.success) {
return res.data.rows;
}
throw new res.error();
} catch (e) {
addToast('Failed to get custom stats', 'Error');
console.error('failed to get custom stats', e);
}
return [];
};
const getStatsById = (id) => {
return customStats.find((s) => s.id === id);
};
const onSubmit = async () => {
try {
isSubmitting = true;
if (modalMode === 'create') {
await create();
} else {
await update();
}
} finally {
isSubmitting = false;
}
};
const buildPayload = () => {
const campaignDate = formValues.date ? new Date(formValues.date).toISOString() : null;
return {
campaignName: formValues.campaignName,
totalRecipients: parseInt(formValues.totalRecipients) || 0,
emailsSent: parseInt(formValues.emailsSent) || 0,
trackingPixelLoaded: parseInt(formValues.trackingPixelLoaded) || 0,
websiteVisits: parseInt(formValues.websiteVisits) || 0,
dataSubmissions: parseInt(formValues.dataSubmissions) || 0,
reported: parseInt(formValues.reported) || 0,
campaignType: 'Scheduled',
templateName: '',
companyId: companyId,
campaignStartDate: campaignDate,
campaignEndDate: campaignDate,
campaignClosedAt: campaignDate
};
};
const create = async () => {
modalError = '';
try {
const res = await api.campaign.createStats(buildPayload());
if (!res.success) {
modalError = res.error;
return;
}
addToast('Custom stats created', 'Success');
closeModal();
} catch (e) {
addToast('Failed to create custom stats', 'Error');
console.error('failed to create custom stats:', e);
}
refreshCustomStats();
};
const update = async () => {
modalError = '';
try {
const res = await api.campaign.updateStats(formValues.id, buildPayload());
if (!res.success) {
modalError = res.error;
return;
}
addToast('Custom stats updated', 'Success');
closeModal();
} catch (e) {
addToast('Failed to update custom stats', 'Error');
console.error('failed to update custom stats', e);
}
refreshCustomStats();
};
const openDeleteAlert = (stats) => {
isDeleteAlertVisible = true;
deleteValues.id = stats.id;
deleteValues.name = stats.campaignName;
};
const onClickDelete = async (id) => {
const action = api.campaign.deleteStats(id);
action
.then((res) => {
if (!res.success) {
throw res.error;
}
refreshCustomStats();
})
.catch((e) => {
console.error('failed to delete custom stats:', e);
});
return action;
};
const openCreateModal = () => {
modalMode = 'create';
modalError = '';
resetFormValues();
isModalVisible = true;
};
const openUpdateModal = (id) => {
modalMode = 'update';
try {
showIsLoading();
const stats = getStatsById(id);
if (stats) {
formValues.id = stats.id;
formValues.campaignName = stats.campaignName;
formValues.totalRecipients = stats.totalRecipients.toString();
formValues.emailsSent = stats.emailsSent.toString();
formValues.trackingPixelLoaded = stats.trackingPixelLoaded.toString();
formValues.websiteVisits = stats.websiteVisits.toString();
formValues.dataSubmissions = stats.dataSubmissions.toString();
formValues.reported = stats.reported.toString();
formValues.date = stats.campaignStartDate
? new Date(stats.campaignStartDate).toISOString().slice(0, 16)
: '';
isModalVisible = true;
}
} catch (e) {
addToast('Failed to get stats', 'Error');
console.error('failed to get stats', e);
} finally {
hideIsLoading();
}
};
const closeModal = () => {
modalError = '';
isModalVisible = false;
resetFormValues();
if (form) form.reset();
};
const resetFormValues = () => {
formValues.id = null;
formValues.campaignName = '';
formValues.totalRecipients = '';
formValues.emailsSent = '';
formValues.trackingPixelLoaded = '';
formValues.websiteVisits = '';
formValues.dataSubmissions = '';
formValues.reported = '';
formValues.date = '';
};
</script>
<p class="text-gray-600 dark:text-gray-300 text-sm mb-2">
Manually recorded campaign results for reporting on activity run outside the platform.
</p>
<BigButton on:click={openCreateModal}>Add</BigButton>
<Table
columns={[
{ column: 'Campaign Name', size: 'large' },
{ column: 'Sent', size: 'small', alignText: 'center' },
{ column: 'Read', size: 'small', alignText: 'center' },
{ column: 'Clicked', size: 'small', alignText: 'center' },
{ column: 'Submitted', size: 'small', alignText: 'center' },
{ column: 'Reported', size: 'small', alignText: 'center' },
{ column: 'Time ago', size: 'small', alignText: 'center' }
]}
sortable={[]}
hasData={!!customStats.length}
plural="custom statistics"
isGhost={isTableLoading}
>
{#each customStats as stats}
{@const pct = getStatPercentages(stats)}
<TableRow>
<TableCell>
<button
on:click={() => openUpdateModal(stats.id)}
class="block w-full py-1 text-left font-medium text-gray-900 dark:text-gray-100"
>
{stats.campaignName}
</button>
</TableCell>
<TableCell alignText="center" value={pct.sent.count} />
<TableCell alignText="center" value={`${pct.read.count} (${pct.read.absolute}%)`} />
<TableCell
alignText="center"
value={`${pct.clicked.count} (${pct.clicked.absolute}%, rel: ${pct.clicked.relative}%)`}
/>
<TableCell
alignText="center"
value={`${pct.submitted.count} (${pct.submitted.absolute}%, rel: ${pct.submitted.relative}%)`}
/>
<TableCell
alignText="center"
value={`${pct.reported.count} (${pct.reported.absolute}%, rel: ${pct.reported.relative}%)`}
/>
<TableCell alignText="center" value={stats.campaignStartDate} isDate isRelative />
<TableCellEmpty />
<TableCellAction>
<TableDropDownEllipsis>
<TableUpdateButton on:click={() => openUpdateModal(stats.id)} />
<TableDeleteButton on:click={() => openDeleteAlert(stats)} />
</TableDropDownEllipsis>
</TableCellAction>
</TableRow>
{/each}
</Table>
<Modal headerText={modalText} visible={isModalVisible} onClose={closeModal} {isSubmitting}>
<FormGrid on:submit={onSubmit} bind:bindTo={form} {isSubmitting} {modalMode}>
<FormColumns>
<FormColumn>
<TextField
minLength={1}
maxLength={64}
required
bind:value={formValues.campaignName}
placeholder="Campaign Name">Campaign Name</TextField
>
<TextField
type="number"
min="0"
required
bind:value={formValues.totalRecipients}
placeholder="0">Total Recipients</TextField
>
<TextField type="number" min="0" bind:value={formValues.emailsSent} placeholder="0"
>Emails Sent</TextField
>
<TextField type="number" min="0" bind:value={formValues.trackingPixelLoaded} placeholder="0"
>Email Opens (Read)</TextField
>
</FormColumn>
<FormColumn>
<TextField type="number" min="0" bind:value={formValues.websiteVisits} placeholder="0"
>Links Clicked</TextField
>
<TextField type="number" min="0" bind:value={formValues.dataSubmissions} placeholder="0"
>Data Submissions</TextField
>
<TextField type="number" min="0" bind:value={formValues.reported} placeholder="0"
>Reported as Phishing</TextField
>
<TextField type="datetime-local" required bind:value={formValues.date}>Date</TextField>
</FormColumn>
</FormColumns>
<FormError message={modalError} />
<FormFooter {closeModal} {isSubmitting} />
</FormGrid>
</Modal>
<DeleteAlert
list={['All statistics data will be permanently removed']}
name={deleteValues.name}
onClick={() => onClickDelete(deleteValues.id)}
bind:isVisible={isDeleteAlertVisible}
/>
@@ -0,0 +1,138 @@
<script>
import { api } from '$lib/api/apiProxy.js';
import { addToast } from '$lib/store/toast';
import { showIsLoading, hideIsLoading } from '$lib/store/loading.js';
import Modal from '../Modal.svelte';
import FormGrid from '../FormGrid.svelte';
import FormFooter from '../FormFooter.svelte';
import FormError from '../FormError.svelte';
import Editor from '../editor/Editor.svelte';
// external
export let visible = false;
/** @type {{ id: string, name: string } | null} */
export let company = null;
// local state
let content = '';
let templateID = null;
let error = '';
let isSubmitting = false;
let loadedForCompanyID = null;
// reactive: load the company template when the modal opens
$: {
if (visible && company && loadedForCompanyID !== company.id) {
loadedForCompanyID = company.id;
load();
}
if (!visible) {
loadedForCompanyID = null;
}
}
const load = async () => {
content = '';
templateID = null;
error = '';
try {
showIsLoading();
const response = await api.reportTemplate.getAll(company.id);
if (response.success && response.data?.rows?.length > 0) {
const tmpl = response.data.rows[0];
content = tmpl.content || '';
templateID = tmpl.id || null;
}
} catch (e) {
console.error('Failed to load company report template:', e);
error = 'Failed to load template';
} finally {
hideIsLoading();
}
};
const close = () => {
visible = false;
error = '';
};
const onSubmit = async (event) => {
const saveOnly = event?.detail?.saveOnly || false;
isSubmitting = true;
error = '';
try {
let response;
if (templateID) {
response = await api.reportTemplate.update(templateID, { content });
} else {
response = await api.reportTemplate.create({
content,
companyID: company.id
});
if (response.success && response.data?.id) {
templateID = response.data.id;
}
}
if (response.success) {
addToast('Report template saved', 'Success');
if (!saveOnly) {
visible = false;
}
} else {
error = response.error || 'Failed to save template';
}
} catch (e) {
console.error('Failed to save company report template:', e);
error = 'Failed to save template';
} finally {
isSubmitting = false;
}
};
const onDelete = async () => {
if (!templateID) return;
isSubmitting = true;
try {
const response = await api.reportTemplate.delete(templateID);
if (response.success) {
addToast('Report template deleted', 'Success');
templateID = null;
content = '';
visible = false;
} else {
error = response.error || 'Failed to delete template';
}
} catch (e) {
console.error('Failed to delete company report template:', e);
error = 'Failed to delete template';
} finally {
isSubmitting = false;
}
};
</script>
{#if visible}
<Modal bind:visible headerText="Report Template — {company?.name}" onClose={close}>
<FormGrid on:submit={onSubmit} {isSubmitting} modalMode="update">
<div
class="w-80vw col-start-1 col-end-4 row-start-1 py-8 px-6 flex flex-col bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 transition-colors duration-200"
>
<Editor contentType="report" bind:value={content} />
<FormError message={error} />
{#if templateID}
<div class="mt-4">
<button
type="button"
class="text-sm text-red-600 dark:text-red-400 hover:underline"
on:click={onDelete}
disabled={isSubmitting}
>
Delete company template (fall back to global)
</button>
</div>
{/if}
</div>
<FormFooter {isSubmitting} closeModal={close} />
</FormGrid>
</Modal>
{/if}
+4 -426
View File
@@ -5,7 +5,6 @@
import Headline from '$lib/components/Headline.svelte';
import TableRow from '$lib/components/table/TableRow.svelte';
import TableCell from '$lib/components/table/TableCell.svelte';
import TableUpdateButton from '$lib/components/table/TableUpdateButton.svelte';
import TableDeleteButton from '$lib/components/table/TableDeleteButton2.svelte';
import FormError from '$lib/components/FormError.svelte';
import { addToast } from '$lib/store/toast';
@@ -20,16 +19,10 @@
import { showIsLoading, hideIsLoading } from '$lib/store/loading.js';
import TableDropDownEllipsis from '$lib/components/table/TableDropDownEllipsis.svelte';
import DeleteAlert from '$lib/components/modal/DeleteAlert.svelte';
import TableDropDownButton from '$lib/components/table/TableDropDownButton.svelte';
import Alert from '$lib/components/Alert.svelte';
import ScimModal from '$lib/components/modal/ScimModal.svelte';
import Editor from '$lib/components/editor/Editor.svelte';
import FormGrid from '$lib/components/FormGrid.svelte';
// bindings
let form = null;
let companyAutoPruneEnabled = false;
let companyAutoPruneEnabledOriginal = false;
const formValues = {
name: null,
comment: null
@@ -42,8 +35,6 @@
let isModalVisible = false;
let isSubmitting = false;
let isTableLoading = true;
let modalMode = null;
let modalText = '';
let isDeleteAlertVisible = false;
let deleteValues = {
@@ -51,26 +42,7 @@
name: null
};
let isViewCommentModalVisible = false;
let viewCommentCompany = null;
let isExportCompanyModalVisible = false;
let isExportSharedModalVisible = false;
let exportCompany = null;
let isScimModalVisible = false;
let scimCompany = null;
let isCompanyReportTemplateModalVisible = false;
let companyReportTemplateContent = '';
let companyReportTemplateID = null;
let companyReportTemplateError = '';
let isCompanyReportTemplateSubmitting = false;
let activeReportTemplateCompany = null;
$: {
modalText = modalMode === 'create' ? 'New company' : 'Update company';
}
// hooks
onMount(() => {
@@ -96,24 +68,6 @@
}
};
/**
* Gets a company by ID
* @param {string} id
*/
const getCompany = async (id) => {
try {
const res = await api.company.getByID(id);
if (res.success) {
return res.data;
} else {
throw res.error;
}
} catch (e) {
addToast('Failed to get company', 'Error');
console.error('failed to get company', e);
}
};
const getCompanies = async () => {
try {
const res = await api.company.getAll(tableURLParams);
@@ -131,11 +85,7 @@
const onSubmit = async () => {
try {
isSubmitting = true;
if (modalMode === 'create') {
await create();
} else {
await update();
}
await create();
} finally {
isSubmitting = false;
}
@@ -158,38 +108,6 @@
refreshCompanies();
};
const update = async () => {
modalError = '';
try {
const res = await api.company.update(formValues.id, formValues.name, formValues.comment);
if (!res.success) {
modalError = res.error;
return;
}
if (companyAutoPruneEnabled !== companyAutoPruneEnabledOriginal) {
await saveCompanyAutoPrune(formValues.id, companyAutoPruneEnabled);
}
addToast('Company updated', 'Success');
closeUpdateModal();
} catch (e) {
addToast('Failed to update company', 'Error');
console.error('failed to update company', e);
}
refreshCompanies();
};
const saveCompanyAutoPrune = async (id, enabled) => {
try {
const res = await api.company.setAutoPrune(id, enabled);
if (!res.success) {
addToast('Failed to save auto-prune setting', 'Error');
}
} catch (e) {
addToast('Failed to save auto-prune setting', 'Error');
console.error('failed to save company auto-prune', e);
}
};
const openDeleteAlert = async (company) => {
isDeleteAlertVisible = true;
deleteValues.id = company.id;
@@ -216,10 +134,7 @@
};
const openCreateModal = () => {
modalMode = 'create';
modalError = '';
// reset form values for create mode
formValues.id = null;
formValues.name = null;
formValues.comment = null;
isModalVisible = true;
@@ -228,110 +143,20 @@
const closeModal = () => {
modalError = '';
isModalVisible = false;
// reset form values
formValues.id = null;
formValues.name = null;
formValues.comment = null;
form.reset();
};
/**
* @param {string} id
*/
const openUpdateModal = async (id) => {
modalMode = 'update';
try {
showIsLoading();
const company = await getCompany(id);
formValues.id = company.id;
formValues.name = company.name;
formValues.comment = company.comment || null;
try {
const optRes = await api.company.getAutoPrune(id);
companyAutoPruneEnabled = optRes.success && optRes.data?.enabled === true;
companyAutoPruneEnabledOriginal = companyAutoPruneEnabled;
} catch (_) {
companyAutoPruneEnabled = false;
}
isModalVisible = true;
} catch (e) {
addToast('Failed to get company', 'Error');
console.error('failed to get company', e);
} finally {
hideIsLoading();
}
};
const closeUpdateModal = () => {
isModalVisible = false;
modalError = '';
// reset form values
formValues.id = null;
formValues.name = null;
formValues.comment = null;
companyAutoPruneEnabled = false;
companyAutoPruneEnabledOriginal = false;
form.reset();
};
const openViewCommentModal = (company) => {
viewCommentCompany = company;
isViewCommentModalVisible = true;
};
const closeViewCommentModal = () => {
isViewCommentModalVisible = false;
viewCommentCompany = null;
};
const openExportCompanyModal = (company) => {
exportCompany = company;
isExportCompanyModalVisible = true;
};
const closeExportCompanyModal = () => {
isExportCompanyModalVisible = false;
exportCompany = null;
};
const openExportSharedModal = () => {
isExportSharedModalVisible = true;
};
const closeExportSharedModal = () => {
isExportSharedModalVisible = false;
};
const openScimModal = (company) => {
scimCompany = company;
isScimModalVisible = true;
};
const closeScimModal = () => {
isScimModalVisible = false;
scimCompany = null;
};
const onConfirmExportCompany = async () => {
try {
showIsLoading();
api.company.export(exportCompany.id);
closeExportCompanyModal();
return { success: true };
} catch (e) {
addToast('Failed to export company events', 'Error');
console.error('failed to export company events', e);
return { success: false, error: e };
} finally {
hideIsLoading();
}
};
const onConfirmExportShared = async () => {
try {
showIsLoading();
api.company.export();
closeExportSharedModal();
isExportSharedModalVisible = false;
return { success: true };
} catch (e) {
addToast('Failed to export shared events', 'Error');
@@ -341,90 +166,6 @@
hideIsLoading();
}
};
const openCompanyReportTemplateModal = async (company) => {
activeReportTemplateCompany = company;
companyReportTemplateContent = '';
companyReportTemplateID = null;
companyReportTemplateError = '';
try {
showIsLoading();
const response = await api.reportTemplate.getAll(company.id);
if (response.success && response.data?.rows?.length > 0) {
const tmpl = response.data.rows[0];
companyReportTemplateContent = tmpl.content || '';
companyReportTemplateID = tmpl.id || null;
}
} catch (error) {
console.error('Failed to load company report template:', error);
companyReportTemplateError = 'Failed to load template';
} finally {
hideIsLoading();
isCompanyReportTemplateModalVisible = true;
}
};
const closeCompanyReportTemplateModal = () => {
isCompanyReportTemplateModalVisible = false;
activeReportTemplateCompany = null;
companyReportTemplateError = '';
};
const onSubmitCompanyReportTemplate = async (event) => {
const saveOnly = event?.detail?.saveOnly || false;
isCompanyReportTemplateSubmitting = true;
companyReportTemplateError = '';
try {
let response;
if (companyReportTemplateID) {
response = await api.reportTemplate.update(companyReportTemplateID, {
content: companyReportTemplateContent
});
} else {
response = await api.reportTemplate.create({
content: companyReportTemplateContent,
companyID: activeReportTemplateCompany.id
});
if (response.success && response.data?.id) {
companyReportTemplateID = response.data.id;
}
}
if (response.success) {
addToast('Report template saved', 'Success');
if (!saveOnly) {
isCompanyReportTemplateModalVisible = false;
}
} else {
companyReportTemplateError = response.error || 'Failed to save template';
}
} catch (error) {
console.error('Failed to save company report template:', error);
companyReportTemplateError = 'Failed to save template';
} finally {
isCompanyReportTemplateSubmitting = false;
}
};
const onDeleteCompanyReportTemplate = async () => {
if (!companyReportTemplateID) return;
isCompanyReportTemplateSubmitting = true;
try {
const response = await api.reportTemplate.delete(companyReportTemplateID);
if (response.success) {
addToast('Report template deleted', 'Success');
companyReportTemplateID = null;
companyReportTemplateContent = '';
isCompanyReportTemplateModalVisible = false;
} else {
companyReportTemplateError = response.error || 'Failed to delete template';
}
} catch (error) {
console.error('Failed to delete company report template:', error);
companyReportTemplateError = 'Failed to delete template';
} finally {
isCompanyReportTemplateSubmitting = false;
}
};
</script>
<HeadTitle title="companies" />
@@ -445,9 +186,7 @@
<TableRow>
<TableCell>
<button
on:click={() => {
openUpdateModal(company.id);
}}
on:click={() => goto(`/company/${company.id}`)}
class="block w-full py-1 text-left"
>
{company.name}
@@ -456,21 +195,6 @@
<TableCellEmpty />
<TableCellAction>
<TableDropDownEllipsis>
<TableUpdateButton on:click={() => openUpdateModal(company.id)} />
<TableDropDownButton
name="View Comment"
on:click={() => openViewCommentModal(company)}
/>
<TableDropDownButton name="Export" on:click={() => openExportCompanyModal(company)} />
<TableDropDownButton name="SCIM" on:click={() => openScimModal(company)} />
<TableDropDownButton
name="Custom Stats"
on:click={() => goto(`/company/${company.id}/stats`)}
/>
<TableDropDownButton
name="Report Template"
on:click={() => openCompanyReportTemplateModal(company)}
/>
<TableDeleteButton on:click={() => openDeleteAlert(company)} />
</TableDropDownEllipsis>
</TableCellAction>
@@ -478,7 +202,7 @@
{/each}
</Table>
<Modal headerText={modalText} visible={isModalVisible} onClose={closeModal} {isSubmitting}>
<Modal headerText="New company" visible={isModalVisible} onClose={closeModal} {isSubmitting}>
<div class="w-[1000px] p-6">
<form on:submit|preventDefault={onSubmit} bind:this={form}>
<div class="space-y-6">
@@ -526,56 +250,6 @@
class="w-full p-4 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 resize-y dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
<div>
<div class="flex items-center mb-2">
<p class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Auto-Prune Orphaned Recipients
</p>
</div>
<div class="inline-flex flex-col space-y-2 min-w-64 mb-4">
<label
class="flex items-start gap-3 p-3 border rounded-lg cursor-pointer transition-colors {companyAutoPruneEnabled
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-500 dark:border-blue-600'
: 'border-gray-300 dark:border-gray-600'}"
>
<input
type="radio"
checked={companyAutoPruneEnabled}
on:change={() => (companyAutoPruneEnabled = true)}
class="mt-0.5 w-4 h-4 text-blue-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:ring-2"
/>
<div class="text-left flex-1">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 block"
>Enabled</span
>
<span class="text-xs text-gray-500 dark:text-gray-400 block mt-0.5"
>Orphaned recipients are deleted automatically each hour</span
>
</div>
</label>
<label
class="flex items-start gap-3 p-3 border rounded-lg cursor-pointer transition-colors {!companyAutoPruneEnabled
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-500 dark:border-blue-600'
: 'border-gray-300 dark:border-gray-600'}"
>
<input
type="radio"
checked={!companyAutoPruneEnabled}
on:change={() => (companyAutoPruneEnabled = false)}
class="mt-0.5 w-4 h-4 text-blue-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:ring-2"
/>
<div class="text-left flex-1">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 block"
>Disabled</span
>
<span class="text-xs text-gray-500 dark:text-gray-400 block mt-0.5"
>Orphaned recipients are kept until manually deleted</span
>
</div>
</label>
</div>
</div>
</div>
<FormError message={modalError} />
@@ -584,41 +258,6 @@
</div>
</Modal>
<Modal
headerText="Company Comment"
visible={isViewCommentModalVisible}
onClose={closeViewCommentModal}
>
<div class="p-8 w-full min-w-[800px] max-w-6xl">
<div class="mb-4">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2">
{viewCommentCompany?.name || 'Company'}
</h3>
</div>
{#if viewCommentCompany?.comment && viewCommentCompany.comment.trim()}
<div
class="bg-gray-50 dark:bg-gray-800 p-8 rounded-lg border min-h-[400px] max-h-[600px] overflow-y-auto"
>
<pre
class="whitespace-pre-wrap text-base text-gray-700 dark:text-gray-300 font-normal leading-relaxed">{viewCommentCompany.comment}</pre>
</div>
{:else}
<div class="bg-gray-50 dark:bg-gray-800 p-8 rounded-lg border text-center">
<p class="text-sm text-gray-500 dark:text-gray-400 italic">No comment available.</p>
</div>
{/if}
<div class="mt-6 flex justify-end">
<button
type="button"
on:click={closeViewCommentModal}
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-md transition-colors duration-200"
>
Close
</button>
</div>
</div>
</Modal>
<DeleteAlert
list={['All data related to the company such as domains, campaign, recipients will be lost']}
name={deleteValues.name}
@@ -626,27 +265,6 @@
bind:isVisible={isDeleteAlertVisible}
></DeleteAlert>
<Alert
headline="Export Company Data"
bind:visible={isExportCompanyModalVisible}
onConfirm={onConfirmExportCompany}
>
<div>
{#if exportCompany}
<p class="mb-4">Are you sure you want to export all data for:</p>
<div class="bg-gray-50 dark:bg-gray-700 p-3 rounded mb-4">
<p class="font-medium">{exportCompany.name}</p>
</div>
<p class="text-sm text-gray-500">
This will download a ZIP file containing all company data, recipients, and campaign
events.
</p>
{/if}
</div>
</Alert>
<ScimModal bind:visible={isScimModalVisible} company={scimCompany} />
<Alert
headline="Export Shared Data"
bind:visible={isExportSharedModalVisible}
@@ -660,44 +278,4 @@
</p>
</div>
</Alert>
{#if isCompanyReportTemplateModalVisible}
<Modal
bind:visible={isCompanyReportTemplateModalVisible}
headerText="Report Template {activeReportTemplateCompany?.name}"
onClose={closeCompanyReportTemplateModal}
>
<FormGrid
on:submit={onSubmitCompanyReportTemplate}
isSubmitting={isCompanyReportTemplateSubmitting}
modalMode="update"
>
<div
class="w-80vw col-start-1 col-end-4 row-start-1 py-8 px-6 flex flex-col bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 transition-colors duration-200"
>
<Editor
contentType="report"
bind:value={companyReportTemplateContent}
/>
<FormError message={companyReportTemplateError} />
{#if companyReportTemplateID}
<div class="mt-4">
<button
type="button"
class="text-sm text-red-600 dark:text-red-400 hover:underline"
on:click={onDeleteCompanyReportTemplate}
disabled={isCompanyReportTemplateSubmitting}
>
Delete company template (fall back to global)
</button>
</div>
{/if}
</div>
<FormFooter
isSubmitting={isCompanyReportTemplateSubmitting}
closeModal={closeCompanyReportTemplateModal}
/>
</FormGrid>
</Modal>
{/if}
</main>
@@ -0,0 +1,364 @@
<script>
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { api } from '$lib/api/apiProxy.js';
import { addToast } from '$lib/store/toast';
import { showIsLoading, hideIsLoading } from '$lib/store/loading.js';
import HeadTitle from '$lib/components/HeadTitle.svelte';
import SettingsCard from '$lib/components/SettingsCard.svelte';
import SettingsLoading from '$lib/components/SettingsLoading.svelte';
import RadioOption from '$lib/components/RadioOption.svelte';
import FormButton from '$lib/components/FormButton.svelte';
import FormError from '$lib/components/FormError.svelte';
import TextField from '$lib/components/TextField.svelte';
import Alert from '$lib/components/Alert.svelte';
import DeleteAlert from '$lib/components/modal/DeleteAlert.svelte';
import ScimModal from '$lib/components/modal/ScimModal.svelte';
import CompanyReportTemplateModal from '$lib/components/modal/CompanyReportTemplateModal.svelte';
import CompanyCustomStats from '$lib/components/company/CompanyCustomStats.svelte';
$: companyId = $page.params.id;
let loaded = false;
let company = null;
// general form
let formValues = {
name: '',
comment: ''
};
let generalError = '';
let isSaving = false;
// auto-prune (saved on change, like display mode in settings)
let autoPruneEnabled = false;
let isSavingAutoPrune = false;
// SCIM status shown in the Integrations tab
let scimStatus = 'none'; // 'none' | 'disabled' | 'enabled'
// modals
let isScimModalVisible = false;
let isReportTemplateModalVisible = false;
let isExportAlertVisible = false;
let isDeleteAlertVisible = false;
// tabs
const tabs = [
{ id: 'general', label: 'General' },
{ id: 'stats', label: 'Custom Stats' },
{ id: 'integrations', label: 'Integrations' },
{ id: 'reports', label: 'Reports' },
{ id: 'data', label: 'Data' },
{ id: 'danger', label: 'Danger Zone' }
];
let active = 'general';
onMount(async () => {
const hash = window.location.hash.replace('#', '');
if (tabs.some((t) => t.id === hash)) {
active = hash;
}
await load();
loaded = true;
});
const selectTab = (id) => {
active = id;
window.location.hash = id;
};
const load = async () => {
await Promise.all([loadCompany(), loadAutoPrune(), loadScimStatus()]);
};
const loadCompany = async () => {
try {
const res = await api.company.getByID(companyId);
if (!res.success) {
throw res.error;
}
company = res.data;
formValues.name = company.name || '';
formValues.comment = company.comment || '';
} catch (e) {
addToast('Failed to get company', 'Error');
console.error('failed to get company', e);
}
};
const loadAutoPrune = async () => {
try {
const res = await api.company.getAutoPrune(companyId);
autoPruneEnabled = res.success && res.data?.enabled === true;
} catch (_) {
autoPruneEnabled = false;
}
};
const loadScimStatus = async () => {
try {
const res = await api.company.scim.getByCompanyID(companyId);
if (res.success && res.data) {
scimStatus = res.data.enabled ? 'enabled' : 'disabled';
} else {
scimStatus = 'none';
}
} catch (_) {
scimStatus = 'none';
}
};
const onSaveGeneral = async () => {
generalError = '';
isSaving = true;
try {
const res = await api.company.update(companyId, formValues.name, formValues.comment);
if (!res.success) {
generalError = res.error;
return;
}
addToast('Company updated', 'Success');
await loadCompany();
} catch (e) {
addToast('Failed to update company', 'Error');
console.error('failed to update company', e);
} finally {
isSaving = false;
}
};
const setAutoPrune = async (enabled) => {
if (enabled === autoPruneEnabled) {
return;
}
isSavingAutoPrune = true;
try {
const res = await api.company.setAutoPrune(companyId, enabled);
if (res.success) {
autoPruneEnabled = enabled;
addToast('Auto-prune setting updated', 'Success');
} else {
addToast('Failed to save auto-prune setting', 'Error');
}
} catch (e) {
addToast('Failed to save auto-prune setting', 'Error');
console.error('failed to save auto-prune', e);
} finally {
isSavingAutoPrune = false;
}
};
const onConfirmExport = async () => {
try {
showIsLoading();
api.company.export(companyId);
isExportAlertVisible = false;
return { success: true };
} catch (e) {
addToast('Failed to export company events', 'Error');
console.error('failed to export company events', e);
return { success: false, error: e };
} finally {
hideIsLoading();
}
};
const onConfirmDelete = async () => {
const res = await api.company.delete(companyId);
if (res.success) {
addToast('Company deleted', 'Success');
goto('/company');
}
return res;
};
</script>
<HeadTitle title={company ? company.name : 'Company'} />
<main>
{#if !loaded}
<SettingsLoading />
{:else}
<nav class="mt-2 mb-1 text-sm">
<a
href="/company"
class="text-gray-500 dark:text-gray-400 hover:text-cta-blue dark:hover:text-highlight-blue transition-colors"
>
Companies
</a>
<span class="text-gray-400 dark:text-gray-600 mx-2">/</span>
<span class="text-gray-700 dark:text-gray-300">{company?.name}</span>
</nav>
<nav class="mt-4 mb-6 border-b border-gray-200 dark:border-gray-700">
<div class="flex">
{#each tabs as tab}
<button
on:click={() => selectTab(tab.id)}
class="px-6 py-3 text-sm font-medium border-b-2 transition-colors
{active === tab.id
? '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'}"
>
{tab.label}
</button>
{/each}
</div>
</nav>
<div class="pb-8">
{#if active === 'general'}
<div class="flex flex-wrap gap-6">
<SettingsCard title="Company Details" widthClass="w-full lg:w-[34rem]">
<form on:submit|preventDefault={onSaveGeneral} class="flex flex-col flex-1">
<TextField
required
width="full"
minLength={1}
maxLength={64}
bind:value={formValues.name}>Company Name</TextField
>
<div class="flex flex-col py-2">
<p class="font-semibold text-slate-600 dark:text-gray-400 py-2">Comment</p>
<textarea
bind:value={formValues.comment}
maxlength={1000000}
rows="6"
placeholder="Add notes about this company..."
class="w-full p-3 rounded-md text-gray-600 dark:text-gray-300 border border-transparent dark:border-gray-700/60 bg-grayblue-light dark:bg-gray-900/60 focus:outline-none focus:border-slate-400 dark:focus:border-highlight-blue/80 focus:bg-gray-100 dark:focus:bg-gray-700/60 resize-y transition-colors duration-200"
/>
</div>
<FormError message={generalError} />
<div class="mt-6 flex justify-end">
<FormButton size="medium" isSubmitting={isSaving}>Save Changes</FormButton>
</div>
</form>
</SettingsCard>
<SettingsCard title="Auto-Prune Orphaned Recipients">
<p class="text-gray-600 dark:text-gray-300 text-sm mb-4">
Choose whether orphaned recipients are removed automatically.
</p>
<div class="space-y-2">
<RadioOption
checked={autoPruneEnabled}
label="Enabled"
description="Orphaned recipients are deleted automatically each hour"
on:change={() => setAutoPrune(true)}
/>
<RadioOption
checked={!autoPruneEnabled}
label="Disabled"
description="Orphaned recipients are kept until manually deleted"
on:change={() => setAutoPrune(false)}
/>
</div>
</SettingsCard>
</div>
{:else if active === 'stats'}
<CompanyCustomStats {companyId} />
{:else if active === 'integrations'}
<div class="flex flex-wrap gap-6">
<SettingsCard title="SCIM Provisioning">
<p class="text-gray-600 dark:text-gray-300 text-sm mb-4">
Automatically provision recipients from your identity provider.
</p>
<div class="flex items-center gap-2 mb-6">
<span class="text-sm text-gray-500 dark:text-gray-400">Status</span>
<span
class="text-xs font-semibold px-2 py-1 rounded-full
{scimStatus === 'enabled'
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
: scimStatus === 'disabled'
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'}"
>
{scimStatus === 'enabled'
? 'Enabled'
: scimStatus === 'disabled'
? 'Disabled'
: 'Not configured'}
</span>
</div>
<div class="mt-auto flex justify-end">
<FormButton size="medium" on:click={() => (isScimModalVisible = true)}
>Configure SCIM</FormButton
>
</div>
</SettingsCard>
</div>
{:else if active === 'reports'}
<div class="flex flex-wrap gap-6">
<SettingsCard title="Report Template">
<p class="text-gray-600 dark:text-gray-300 text-sm mb-4">
Override the global report template for this company.
</p>
<div class="mt-auto flex justify-end">
<FormButton size="medium" on:click={() => (isReportTemplateModalVisible = true)}
>Edit template</FormButton
>
</div>
</SettingsCard>
</div>
{:else if active === 'data'}
<div class="flex flex-wrap gap-6">
<SettingsCard title="Export">
<p class="text-gray-600 dark:text-gray-300 text-sm mb-4">
Download a ZIP with all company data, recipients, and campaign events.
</p>
<div class="mt-auto flex justify-end">
<FormButton size="medium" on:click={() => (isExportAlertVisible = true)}
>Export data</FormButton
>
</div>
</SettingsCard>
</div>
{:else if active === 'danger'}
<div class="flex flex-wrap gap-6">
<SettingsCard title="Delete Company">
<p class="text-gray-600 dark:text-gray-300 text-sm mb-4">
Permanently removes this company and all of its domains, campaigns, and recipients.
This cannot be undone.
</p>
<div class="mt-auto flex justify-end">
<button
type="button"
on:click={() => (isDeleteAlertVisible = true)}
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-bold uppercase rounded-md transition-colors duration-200"
>
Delete
</button>
</div>
</SettingsCard>
</div>
{/if}
</div>
{/if}
<ScimModal bind:visible={isScimModalVisible} {company} />
<CompanyReportTemplateModal bind:visible={isReportTemplateModalVisible} {company} />
<Alert
headline="Export Company Data"
bind:visible={isExportAlertVisible}
onConfirm={onConfirmExport}
>
<div>
<p class="mb-4">Are you sure you want to export all data for:</p>
<div class="bg-gray-50 dark:bg-gray-700 p-3 rounded mb-4">
<p class="font-medium">{company?.name}</p>
</div>
<p class="text-sm text-gray-500">
This will download a ZIP file containing all company data, recipients, and campaign events.
</p>
</div>
</Alert>
<DeleteAlert
list={['All data related to the company such as domains, campaign, recipients will be lost']}
name={company?.name}
onClick={onConfirmDelete}
bind:isVisible={isDeleteAlertVisible}
/>
</main>
@@ -1,487 +0,0 @@
<script>
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { api } from '$lib/api/apiProxy.js';
import Headline from '$lib/components/Headline.svelte';
import TableRow from '$lib/components/table/TableRow.svelte';
import TableCell from '$lib/components/table/TableCell.svelte';
import TableUpdateButton from '$lib/components/table/TableUpdateButton.svelte';
import TableDeleteButton from '$lib/components/table/TableDeleteButton2.svelte';
import FormError from '$lib/components/FormError.svelte';
import { addToast } from '$lib/store/toast';
import TableCellAction from '$lib/components/table/TableCellAction.svelte';
import Modal from '$lib/components/Modal.svelte';
import BigButton from '$lib/components/BigButton.svelte';
import FormFooter from '$lib/components/FormFooter.svelte';
import Table from '$lib/components/table/Table.svelte';
import HeadTitle from '$lib/components/HeadTitle.svelte';
import { showIsLoading, hideIsLoading } from '$lib/store/loading.js';
import TableDropDownEllipsis from '$lib/components/table/TableDropDownEllipsis.svelte';
import DeleteAlert from '$lib/components/modal/DeleteAlert.svelte';
import TableCellEmpty from '$lib/components/table/TableCellEmpty.svelte';
import TextField from '$lib/components/TextField.svelte';
import FormGrid from '$lib/components/FormGrid.svelte';
import FormColumns from '$lib/components/FormColumns.svelte';
import FormColumn from '$lib/components/FormColumn.svelte';
function getStatPercentages(stats) {
const totalRecipients = stats.totalRecipients || 0;
const emailsSent = stats.emailsSent || 0;
const read = stats.trackingPixelLoaded || 0;
const clicked = stats.websiteVisits || 0;
const submitted = stats.dataSubmissions || 0;
const reported = stats.reported || 0;
function pct(n, d) {
return d > 0 ? Math.round((n / d) * 100) : 0;
}
return {
sent: {
count: emailsSent,
absolute: pct(emailsSent, totalRecipients),
relative: pct(emailsSent, totalRecipients)
},
read: {
count: read,
absolute: pct(read, totalRecipients),
relative: pct(read, emailsSent)
},
clicked: {
count: clicked,
absolute: pct(clicked, totalRecipients),
relative: pct(clicked, read)
},
submitted: {
count: submitted,
absolute: pct(submitted, totalRecipients),
relative: pct(submitted, clicked)
},
reported: {
count: reported,
absolute: pct(reported, totalRecipients),
relative: pct(reported, emailsSent)
}
};
}
// Get company ID from URL params
$: companyId = $page.params.id;
// bindings
let form = null;
const formValues = {
id: null,
campaignName: '',
totalRecipients: '',
emailsSent: '',
trackingPixelLoaded: '',
websiteVisits: '',
dataSubmissions: '',
reported: '',
date: ''
};
// data
let modalError = '';
let customStats = [];
let company = {
name: ''
};
let isModalVisible = false;
let isSubmitting = false;
let isTableLoading = true;
let modalMode = null;
let modalText = '';
let isDeleteAlertVisible = false;
let deleteValues = {
id: null,
name: null
};
$: {
modalText = modalMode === 'create' ? 'New Custom Stats' : 'Update Custom Stats';
}
// hooks
onMount(() => {
refreshData();
});
// component logic
const refreshData = async () => {
await Promise.all([getCompany(), refreshCustomStats()]);
};
const getCompany = async () => {
try {
const res = await api.company.getByID(companyId);
if (res.success) {
company = res.data;
} else {
throw res.error;
}
} catch (e) {
addToast('Failed to get company', 'Error');
console.error('failed to get company', e);
}
};
const refreshCustomStats = async () => {
try {
isTableLoading = true;
customStats = await getCustomStats();
} catch (e) {
addToast('Failed to get custom stats', 'Error');
console.error('failed to get custom stats', e);
} finally {
isTableLoading = false;
}
};
const getCustomStats = async () => {
try {
const res = await api.campaign.getManualCampaignStats(companyId);
if (res.success) {
return res.data.rows;
}
throw new res.error();
} catch (e) {
addToast('Failed to get custom stats', 'Error');
console.error('failed to get custom stats', e);
}
return [];
};
const getStatsById = async (id) => {
try {
// We'll need to implement this endpoint or get it from the list
const stat = customStats.find((s) => s.id === id);
return stat;
} catch (e) {
addToast('Failed to get stats', 'Error');
console.error('failed to get stats', e);
}
};
const onSubmit = async () => {
try {
isSubmitting = true;
if (modalMode === 'create') {
await create();
} else {
await update();
}
} finally {
isSubmitting = false;
}
};
const create = async () => {
modalError = '';
try {
const campaignDate = formValues.date ? new Date(formValues.date).toISOString() : null;
const payload = {
campaignName: formValues.campaignName,
totalRecipients: parseInt(formValues.totalRecipients) || 0,
emailsSent: parseInt(formValues.emailsSent) || 0,
trackingPixelLoaded: parseInt(formValues.trackingPixelLoaded) || 0,
websiteVisits: parseInt(formValues.websiteVisits) || 0,
dataSubmissions: parseInt(formValues.dataSubmissions) || 0,
reported: parseInt(formValues.reported) || 0,
campaignType: 'Scheduled',
templateName: '',
companyId: companyId,
campaignStartDate: campaignDate,
campaignEndDate: campaignDate,
campaignClosedAt: campaignDate
};
const res = await api.campaign.createStats(payload);
if (!res.success) {
modalError = res.error;
return;
}
addToast('Custom stats created', 'Success');
closeModal();
} catch (e) {
addToast('Failed to create custom stats', 'Error');
console.error('failed to create custom stats:', e);
}
refreshCustomStats();
};
const update = async () => {
modalError = '';
try {
const campaignDate = formValues.date ? new Date(formValues.date).toISOString() : null;
const payload = {
campaignName: formValues.campaignName,
totalRecipients: parseInt(formValues.totalRecipients) || 0,
emailsSent: parseInt(formValues.emailsSent) || 0,
trackingPixelLoaded: parseInt(formValues.trackingPixelLoaded) || 0,
websiteVisits: parseInt(formValues.websiteVisits) || 0,
dataSubmissions: parseInt(formValues.dataSubmissions) || 0,
reported: parseInt(formValues.reported) || 0,
campaignType: 'Scheduled',
templateName: '',
companyId: companyId,
campaignStartDate: campaignDate,
campaignEndDate: campaignDate,
campaignClosedAt: campaignDate
};
const res = await api.campaign.updateStats(formValues.id, payload);
if (!res.success) {
modalError = res.error;
return;
}
addToast('Custom stats updated', 'Success');
closeUpdateModal();
} catch (e) {
addToast('Failed to update custom stats', 'Error');
console.error('failed to update custom stats', e);
}
refreshCustomStats();
};
const openDeleteAlert = async (stats) => {
isDeleteAlertVisible = true;
deleteValues.id = stats.id;
deleteValues.name = stats.campaignName;
};
const onClickDelete = async (id) => {
const action = api.campaign.deleteStats(id);
action
.then((res) => {
if (!res.success) {
throw res.error;
}
refreshCustomStats();
})
.catch((e) => {
console.error('failed to delete custom stats:', e);
});
return action;
};
const openCreateModal = () => {
modalMode = 'create';
modalError = '';
resetFormValues();
isModalVisible = true;
};
const closeModal = () => {
modalError = '';
isModalVisible = false;
resetFormValues();
if (form) form.reset();
};
const openUpdateModal = async (id) => {
modalMode = 'update';
try {
showIsLoading();
const stats = await getStatsById(id);
if (stats) {
formValues.id = stats.id;
formValues.campaignName = stats.campaignName;
formValues.totalRecipients = stats.totalRecipients.toString();
formValues.emailsSent = stats.emailsSent.toString();
formValues.trackingPixelLoaded = stats.trackingPixelLoaded.toString();
formValues.websiteVisits = stats.websiteVisits.toString();
formValues.dataSubmissions = stats.dataSubmissions.toString();
formValues.reported = stats.reported.toString();
formValues.date = stats.campaignStartDate
? new Date(stats.campaignStartDate).toISOString().slice(0, 16)
: '';
isModalVisible = true;
}
} catch (e) {
addToast('Failed to get stats', 'Error');
console.error('failed to get stats', e);
} finally {
hideIsLoading();
}
};
const closeUpdateModal = () => {
isModalVisible = false;
modalError = '';
resetFormValues();
if (form) form.reset();
};
const resetFormValues = () => {
formValues.id = null;
formValues.campaignName = '';
formValues.totalRecipients = '';
formValues.emailsSent = '';
formValues.trackingPixelLoaded = '';
formValues.websiteVisits = '';
formValues.dataSubmissions = '';
formValues.reported = '';
formValues.date = '';
};
</script>
<HeadTitle title="Custom Campaign Stats" />
<main>
<Headline>
Custom Stats - {company.name}
</Headline>
<BigButton on:click={openCreateModal}>Add</BigButton>
<script>
function getStatPercentages(stats) {
const totalRecipients = stats.totalRecipients || 0;
const emailsSent = stats.emailsSent || 0;
const read = stats.trackingPixelLoaded || 0;
const clicked = stats.websiteVisits || 0;
const reported = stats.reported || 0;
function pct(n, d) {
return d > 0 ? Math.round((n / d) * 100) : 0;
}
return {
sent: {
count: emailsSent,
absolute: pct(emailsSent, totalRecipients),
relative: pct(emailsSent, totalRecipients)
},
read: {
count: read,
absolute: pct(read, totalRecipients),
relative: pct(read, emailsSent)
},
clicked: {
count: clicked,
absolute: pct(clicked, totalRecipients),
relative: pct(clicked, read)
},
reported: {
count: reported,
absolute: pct(reported, totalRecipients),
relative: pct(reported, emailsSent)
}
};
}
</script>
<Table
columns={[
{ column: 'Campaign Name', size: 'large' },
{ column: 'Sent', size: 'small', alignText: 'center' },
{ column: 'Read', size: 'small', alignText: 'center' },
{ column: 'Clicked', size: 'small', alignText: 'center' },
{ column: 'Submitted', size: 'small', alignText: 'center' },
{ column: 'Reported', size: 'small', alignText: 'center' },
{ column: 'Time ago', size: 'small', alignText: 'center' }
]}
sortable={[]}
hasData={!!customStats.length}
plural="custom statistics"
isGhost={isTableLoading}
>
{#each customStats as stats}
{@const pct = getStatPercentages(stats)}
<TableRow>
<TableCell>
<button
on:click={() => openUpdateModal(stats.id)}
class="block w-full py-1 text-left font-medium text-gray-900 dark:text-gray-100"
>
{stats.campaignName}
</button>
</TableCell>
<TableCell alignText="center" value={pct.sent.count} />
<TableCell alignText="center" value={`${pct.read.count} (${pct.read.absolute}%)`} />
<TableCell
alignText="center"
value={`${pct.clicked.count} (${pct.clicked.absolute}%, rel: ${pct.clicked.relative}%)`}
/>
<TableCell
alignText="center"
value={`${pct.submitted.count} (${pct.submitted.absolute}%, rel: ${pct.submitted.relative}%)`}
/>
<TableCell
alignText="center"
value={`${pct.reported.count} (${pct.reported.absolute}%, rel: ${pct.reported.relative}%)`}
/>
<TableCell alignText="center" value={stats.campaignStartDate} isDate isRelative />
<TableCellEmpty />
<TableCellAction>
<TableDropDownEllipsis>
<TableUpdateButton on:click={() => openUpdateModal(stats.id)} />
<TableDeleteButton on:click={() => openDeleteAlert(stats)} />
</TableDropDownEllipsis>
</TableCellAction>
</TableRow>
{/each}
</Table>
<Modal headerText={modalText} visible={isModalVisible} onClose={closeModal} {isSubmitting}>
<FormGrid on:submit={onSubmit} bind:bindTo={form} {isSubmitting} {modalMode}>
<FormColumns>
<FormColumn>
<TextField
minLength={1}
maxLength={64}
required
bind:value={formValues.campaignName}
placeholder="Campaign Name">Campaign Name</TextField
>
<TextField
type="number"
min="0"
required
bind:value={formValues.totalRecipients}
placeholder="0">Total Recipients</TextField
>
<TextField type="number" min="0" bind:value={formValues.emailsSent} placeholder="0"
>Emails Sent</TextField
>
<TextField
type="number"
min="0"
bind:value={formValues.trackingPixelLoaded}
placeholder="0">Email Opens (Read)</TextField
>
</FormColumn>
<FormColumn>
<TextField type="number" min="0" bind:value={formValues.websiteVisits} placeholder="0"
>Links Clicked</TextField
>
<TextField type="number" min="0" bind:value={formValues.dataSubmissions} placeholder="0"
>Data Submissions</TextField
>
<TextField type="number" min="0" bind:value={formValues.reported} placeholder="0"
>Reported as Phishing</TextField
>
<TextField type="datetime-local" required bind:value={formValues.date}>Date</TextField>
</FormColumn>
</FormColumns>
<FormError message={modalError} />
<FormFooter {closeModal} {isSubmitting} />
</FormGrid>
</Modal>
<DeleteAlert
list={['All statistics data will be permanently removed']}
name={deleteValues.name}
onClick={() => onClickDelete(deleteValues.id)}
bind:isVisible={isDeleteAlertVisible}
/>
</main>