mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-07-03 02:55:54 +02:00
add company page - improve company settings UI
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
@@ -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}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user