added status modal after import recipients

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2025-12-04 11:23:52 +01:00
parent cbaca17756
commit 170f92aa72
6 changed files with 647 additions and 38 deletions
+2 -2
View File
@@ -490,7 +490,7 @@ func (r *Recipient) Import(g *gin.Context) {
if !req.IgnoreOverwriteEmptyFields.IsSpecified() || req.IgnoreOverwriteEmptyFields.IsNull() {
req.IgnoreOverwriteEmptyFields = nullable.NewNullableWithValue(true)
}
_, err := r.RecipientService.Import(
result, err := r.RecipientService.Import(
g,
session,
req.Recipients,
@@ -500,7 +500,7 @@ func (r *Recipient) Import(g *gin.Context) {
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, &gin.H{})
r.Response.OK(g, result)
}
// DeleteByID deletes a recipient by id
+2 -2
View File
@@ -221,7 +221,7 @@ func (r *RecipientGroup) Import(g *gin.Context) {
req.IgnoreOverwriteEmptyFields = nullable.NewNullableWithValue(true)
}
err := r.RecipientGroupService.Import(
result, err := r.RecipientGroupService.Import(
g,
session,
req.Recipients,
@@ -232,7 +232,7 @@ func (r *RecipientGroup) Import(g *gin.Context) {
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, &gin.H{})
r.Response.OK(g, result)
}
// AddRecipients adds recipients to a recipient group
+124 -22
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"github.com/go-errors/errors"
"github.com/oapi-codegen/nullable"
"github.com/google/uuid"
"github.com/phishingclub/phishingclub/data"
@@ -495,37 +496,84 @@ func (r *Recipient) GetByEmail(
// Import imports recipients
// if the recipient does not exists, it will be created and added to the group
// if the recipient exits, it will be updated and added to the group
// RecipientImportResult contains the results of importing recipients
type RecipientImportResult struct {
SuccessIDs []*uuid.UUID `json:"successIDs"`
CreatedRecipients []RecipientImportSuccess `json:"createdRecipients"`
UpdatedRecipients []RecipientImportSuccess `json:"updatedRecipients"`
Failures []RecipientImportFailure `json:"failures"`
Summary RecipientImportSummary `json:"summary"`
}
// RecipientImportSuccess contains information about a successful import
type RecipientImportSuccess struct {
Email string `json:"email"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
Index int `json:"index"`
}
// RecipientImportFailure contains information about a failed import
type RecipientImportFailure struct {
Email string `json:"email"`
Index int `json:"index"`
Reason string `json:"reason"`
}
// RecipientImportSummary contains summary statistics
type RecipientImportSummary struct {
Total int `json:"total"`
Success int `json:"success"`
Failed int `json:"failed"`
Created int `json:"created"`
Updated int `json:"updated"`
}
func (r *Recipient) Import(
ctx context.Context,
session *model.Session,
recipients []*model.Recipient,
ignoreOverwriteEmptyFields bool,
companyID *uuid.UUID,
) ([]*uuid.UUID, error) {
) (*RecipientImportResult, error) {
ae := NewAuditEvent("Recipient.Import", session)
recipientsIDs := []*uuid.UUID{}
result := &RecipientImportResult{
SuccessIDs: []*uuid.UUID{},
CreatedRecipients: []RecipientImportSuccess{},
UpdatedRecipients: []RecipientImportSuccess{},
Failures: []RecipientImportFailure{},
Summary: RecipientImportSummary{
Total: len(recipients),
},
}
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
r.LogAuthError(err)
return recipientsIDs, errs.Wrap(err)
return result, errs.Wrap(err)
}
if !isAuthorized {
r.AuditLogNotAuthorized(ae)
return recipientsIDs, errs.ErrAuthorizationFailed
return result, errs.ErrAuthorizationFailed
}
if len(recipients) == 0 {
return recipientsIDs, validate.WrapErrorWithField(errors.New("no recipients"), "add recipients")
return result, validate.WrapErrorWithField(errors.New("no recipients"), "add recipients")
}
// first validate all the entries
for _, recipient := range recipients {
if err := recipient.Validate(); err != nil {
return recipientsIDs, errs.Wrap(err)
// process each recipient individually, collecting successes and failures
for i, incoming := range recipients {
// validate the recipient
if err := incoming.Validate(); err != nil {
result.Failures = append(result.Failures, RecipientImportFailure{
Email: getEmailFromRecipient(incoming),
Index: i,
Reason: err.Error(),
})
result.Summary.Failed++
continue
}
}
// if the recipient does not exist, create it
// if the recipient exists, update it
for _, incoming := range recipients {
// check if the recipient exists
email := incoming.Email.MustGet()
current, err := r.RecipientRepository.GetByEmail(
@@ -534,8 +582,17 @@ func (r *Recipient) Import(
"id", "email", "company_id",
)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
r.Logger.Debugw("failed to import recipients - failed to get recipient", "error", err)
return recipientsIDs, errs.Wrap(err)
r.Logger.Debugw("failed to import recipient - failed to get recipient",
"error", err,
"email", email.String(),
)
result.Failures = append(result.Failures, RecipientImportFailure{
Email: email.String(),
Index: i,
Reason: "database error: " + err.Error(),
})
result.Summary.Failed++
continue
}
if current == nil {
// create recipient
@@ -548,12 +605,27 @@ func (r *Recipient) Import(
incoming,
)
if err != nil {
r.Logger.Debugw("failed to import recipients - failed to create recipient",
r.Logger.Debugw("failed to import recipient - failed to create recipient",
"error", err,
"email", email.String(),
)
return recipientsIDs, errs.Wrap(err)
result.Failures = append(result.Failures, RecipientImportFailure{
Email: email.String(),
Index: i,
Reason: "create failed: " + err.Error(),
})
result.Summary.Failed++
continue
}
recipientsIDs = append(recipientsIDs, recipientID)
result.SuccessIDs = append(result.SuccessIDs, recipientID)
result.Summary.Success++
result.Summary.Created++
result.CreatedRecipients = append(result.CreatedRecipients, RecipientImportSuccess{
Email: email.String(),
FirstName: getStringFromOptional(incoming.FirstName),
LastName: getStringFromOptional(incoming.LastName),
Index: i,
})
} else {
// set the companyID to NOT SET, so it is not overwritten if supplied
incoming.CompanyID.SetUnspecified()
@@ -571,17 +643,47 @@ func (r *Recipient) Import(
incoming,
)
if err != nil {
r.Logger.Debugw("failed to import recipients - failed to update recipient",
r.Logger.Debugw("failed to import recipient - failed to update recipient",
"error", err,
"email", email.String(),
)
return recipientsIDs, errs.Wrap(err)
result.Failures = append(result.Failures, RecipientImportFailure{
Email: email.String(),
Index: i,
Reason: "update failed: " + err.Error(),
})
result.Summary.Failed++
continue
}
recipientsIDs = append(recipientsIDs, &recipientID)
result.SuccessIDs = append(result.SuccessIDs, &recipientID)
result.Summary.Success++
result.Summary.Updated++
result.UpdatedRecipients = append(result.UpdatedRecipients, RecipientImportSuccess{
Email: email.String(),
FirstName: getStringFromOptional(incoming.FirstName),
LastName: getStringFromOptional(incoming.LastName),
Index: i,
})
}
}
r.AuditLogAuthorized(ae)
return result, nil
}
return recipientsIDs, nil
// getEmailFromRecipient safely extracts email from recipient for error reporting
func getEmailFromRecipient(r *model.Recipient) string {
if r.Email.IsSpecified() && !r.Email.IsNull() {
return r.Email.MustGet().String()
}
return "<no email>"
}
// getStringFromOptional safely extracts string from optional field
func getStringFromOptional(field nullable.Nullable[vo.OptionalString127]) string {
if field.IsSpecified() && !field.IsNull() {
return field.MustGet().String()
}
return ""
}
// Delete deletes a recipient
+10 -10
View File
@@ -92,7 +92,7 @@ func (r *RecipientGroup) Import(
ignoreOverwriteEmptyFields bool,
recipientGroupID *uuid.UUID,
companyID *uuid.UUID,
) error {
) (*RecipientImportResult, error) {
ae := NewAuditEvent("RecipientGroup.Import", session)
ae.Details["recipientGroupId"] = recipientGroupID.String()
if companyID != nil {
@@ -102,14 +102,14 @@ func (r *RecipientGroup) Import(
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
r.LogAuthError(err)
return err
return nil, err
}
if !isAuthorized {
r.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
return nil, errs.ErrAuthorizationFailed
}
if len(recipients) == 0 {
return validate.WrapErrorWithField(errors.New("no recipients"), "add recipients")
return nil, validate.WrapErrorWithField(errors.New("no recipients"), "add recipients")
}
// check that the recipient group exists
_, err = r.RecipientGroupRepository.GetByID(
@@ -119,9 +119,9 @@ func (r *RecipientGroup) Import(
)
if err != nil {
r.Logger.Debugw("failed to import recipients - failed to get recipient group", "error", err)
return err
return nil, err
}
recipientIDs, err := r.RecipientService.Import(
result, err := r.RecipientService.Import(
ctx,
session,
recipients,
@@ -129,24 +129,24 @@ func (r *RecipientGroup) Import(
companyID,
)
if err != nil {
return err
return result, err
}
// add recpients to group
err = r.AddRecipients(
ctx,
session,
recipientGroupID,
recipientIDs,
result.SuccessIDs,
)
if err != nil {
r.Logger.Debugw("failed to import recipients - failed to add recipients to group",
"error", err,
)
return err
return result, err
}
r.AuditLogAuthorized(ae)
return nil
return result, nil
}
// GetByID returns a recipient group by ID
+254 -1
View File
@@ -22,6 +22,7 @@
import { getPaginatedChunkWithParams } from '$lib/service/paginationChunk';
import CheckboxField from '$lib/components/CheckboxField.svelte';
import BigButton from '$lib/components/BigButton.svelte';
import Button from '$lib/components/Button.svelte';
import FormColumns from '$lib/components/FormColumns.svelte';
import FormColumn from '$lib/components/FormColumn.svelte';
import FormFooter from '$lib/components/FormFooter.svelte';
@@ -63,6 +64,8 @@
ignoreOverwriteEmptyFields: true
};
let csvSkippedRows = [];
let importResult = null;
let isImportResultModalVisible = false;
const tableImportParams = newTableParams({ sortBy: 'email' });
let selectedRecipientsImportPaginatedChunk = [];
let isImportModalVisible = false;
@@ -210,8 +213,29 @@
importModalError = res.error;
return;
}
addToast('Recipients imported', 'Success');
// store import result for display
importResult = res.data;
// build summary message
const summary = res.data.summary;
let message = `Import complete: ${summary.success} succeeded (${summary.created} created, ${summary.updated} updated)`;
if (summary.failed > 0) {
message += `, ${summary.failed} failed`;
}
if (csvSkippedRows.length > 0) {
message += `, ${csvSkippedRows.length} skipped in CSV`;
}
console.log(summary);
addToast(
'Import finished',
summary.failed > 0 || csvSkippedRows.length > 0 ? 'Warning' : 'Success'
);
// show result modal
closeImportModal();
isImportResultModalVisible = true;
refreshRecipients();
} catch (err) {
addToast('Failed to import recipients', 'Error');
@@ -289,6 +313,7 @@
const openImportModal = () => {
csvSkippedRows = [];
importModalError = '';
importResult = null;
isImportModalVisible = true;
};
@@ -685,4 +710,232 @@
onClick={() => onClickDelete(deleteValues.id)}
bind:isVisible={isDeleteAlertVisible}
></DeleteAlert>
{#if isImportResultModalVisible && importResult}
<Modal headerText="Recipient Import Summary" bind:visible={isImportResultModalVisible}>
<div class="p-6 max-h-[80vh] overflow-y-auto">
<div class="space-y-6">
<!-- Statistics Section -->
<div class="grid grid-cols-1 gap-6">
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-2">Recipients</h3>
<ul class="space-y-1">
<li>Total: {importResult.summary.total}</li>
<li>Created: {importResult.summary.created}</li>
<li>Updated: {importResult.summary.updated}</li>
<li>Failed: {importResult.summary.failed}</li>
{#if csvSkippedRows.length > 0}
<li>Skipped in CSV: {csvSkippedRows.length}</li>
{/if}
</ul>
</div>
</div>
<!-- Details Section -->
<div class="border-t pt-6">
<div class="space-y-4">
{#if importResult.createdRecipients?.length > 0}
<div
class="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
>
<details class="group">
<summary
class="cursor-pointer p-4 font-semibold text-base text-pc-darkblue dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700/50 rounded-lg transition-colors list-none flex items-center gap-2"
>
<svg
class="w-4 h-4 transition-transform group-open:rotate-90"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
<span>Created ({importResult.createdRecipients.length})</span>
</summary>
<div class="px-4 pb-4">
<div class="space-y-1">
{#each importResult.createdRecipients as recipient}
<div
class="flex items-center justify-between py-2 px-3 rounded hover:bg-white dark:hover:bg-gray-700/50 transition-colors"
>
<span
class="text-sm text-gray-900 dark:text-gray-100 font-medium truncate flex-1"
>
{recipient.email}
</span>
{#if recipient.firstName || recipient.lastName}
<span
class="text-sm text-gray-500 dark:text-gray-400 ml-4 whitespace-nowrap"
>
{recipient.firstName || ''}
{recipient.lastName || ''}
</span>
{/if}
</div>
{/each}
</div>
</div>
</details>
</div>
{/if}
{#if importResult.updatedRecipients?.length > 0}
<div
class="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
>
<details class="group">
<summary
class="cursor-pointer p-4 font-semibold text-base text-pc-darkblue dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700/50 rounded-lg transition-colors list-none flex items-center gap-2"
>
<svg
class="w-4 h-4 transition-transform group-open:rotate-90"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
<span>Updated ({importResult.updatedRecipients.length})</span>
</summary>
<div class="px-4 pb-4">
<div class="space-y-1">
{#each importResult.updatedRecipients as recipient}
<div
class="flex items-center justify-between py-2 px-3 rounded hover:bg-white dark:hover:bg-gray-700/50 transition-colors"
>
<span
class="text-sm text-gray-900 dark:text-gray-100 font-medium truncate flex-1"
>
{recipient.email}
</span>
{#if recipient.firstName || recipient.lastName}
<span
class="text-sm text-gray-500 dark:text-gray-400 ml-4 whitespace-nowrap"
>
{recipient.firstName || ''}
{recipient.lastName || ''}
</span>
{/if}
</div>
{/each}
</div>
</div>
</details>
</div>
{/if}
{#if csvSkippedRows.length > 0}
<div
class="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
>
<details class="group">
<summary
class="cursor-pointer p-4 font-semibold text-base text-pc-darkblue dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700/50 rounded-lg transition-colors list-none flex items-center gap-2"
>
<svg
class="w-4 h-4 transition-transform group-open:rotate-90"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
<span>Skipped in CSV ({csvSkippedRows.length})</span>
</summary>
<div class="px-4 pb-4">
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3">
These rows were skipped during CSV parsing (before import)
</p>
<div class="space-y-1">
{#each csvSkippedRows as skip}
<div
class="flex items-center justify-between py-2 px-3 rounded hover:bg-white dark:hover:bg-gray-700/50 transition-colors"
>
<span
class="text-sm text-gray-900 dark:text-gray-100 font-medium truncate flex-1"
>
Line {skip.line}: {skip.reason}
{#if skip.row?.email}
<span class="text-gray-500 dark:text-gray-400 ml-2"
>({skip.row.email})</span
>
{/if}
</span>
</div>
{/each}
</div>
</div>
</details>
</div>
{/if}
{#if importResult.failures?.length > 0}
<div
class="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
>
<details class="group">
<summary
class="cursor-pointer p-4 font-semibold text-base text-pc-darkblue dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700/50 rounded-lg transition-colors list-none flex items-center gap-2"
>
<svg
class="w-4 h-4 transition-transform group-open:rotate-90"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
<span>Import Errors ({importResult.failures.length})</span>
</summary>
<div class="px-4 pb-4">
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3">
These recipients failed to import (backend errors)
</p>
<div class="space-y-1">
{#each importResult.failures as err}
<div
class="flex items-center justify-between py-2 px-3 rounded hover:bg-white dark:hover:bg-gray-700/50 transition-colors"
>
<span
class="text-sm text-gray-900 dark:text-gray-100 font-medium truncate flex-1"
>
{err.email}: {err.reason}
</span>
</div>
{/each}
</div>
</div>
</details>
</div>
{/if}
</div>
</div>
<div class="mt-4 flex justify-end">
<Button on:click={() => (isImportResultModalVisible = false)}>Close</Button>
</div>
</div>
</div>
</Modal>
{/if}
</section>
@@ -24,6 +24,7 @@
import { parseCSVToRecipients } from '$lib/utils/csv';
import CheckboxField from '$lib/components/CheckboxField.svelte';
import BigButton from '$lib/components/BigButton.svelte';
import Button from '$lib/components/Button.svelte';
import FormColumns from '$lib/components/FormColumns.svelte';
import FormColumn from '$lib/components/FormColumn.svelte';
import Table from '$lib/components/table/Table.svelte';
@@ -62,6 +63,8 @@
ignoreOverwriteEmptyFields: true
};
let csvSkippedRows = [];
let importResult = null;
let isImportResultModalVisible = false;
const tableImportParams = newTableParams({ sortBy: 'email' });
let selectedRecipientsImportPaginatedChunk = [];
let isImportModalVisible = false;
@@ -264,8 +267,29 @@
importError = res.error;
return;
}
addToast('Recipients imported to group', 'Success');
// store import result for display
importResult = res.data;
// build summary message
const summary = res.data.summary;
let message = `Import complete: ${summary.success} succeeded (${summary.created} created, ${summary.updated} updated)`;
if (summary.failed > 0) {
message += `, ${summary.failed} failed`;
}
if (csvSkippedRows.length > 0) {
message += `, ${csvSkippedRows.length} skipped in CSV`;
}
console.log(summary);
addToast(
'Import finished',
summary.failed > 0 || csvSkippedRows.length > 0 ? 'Warning' : 'Success'
);
// show result modal
closeImportModal();
isImportResultModalVisible = true;
refreshRecipients();
} catch (err) {
addToast('Failed to import recipients to group', 'Error');
@@ -286,6 +310,7 @@
// track skipped rows
if (result.skipped && result.skipped.length > 0) {
csvSkippedRows = csvSkippedRows.concat(result.skipped);
console.info(`CSV import: ${result.skipped.length} rows skipped`, result.skipped);
}
@@ -353,6 +378,7 @@
const openImportModal = () => {
csvSkippedRows = [];
importError = '';
importResult = null;
isImportModalVisible = true;
};
@@ -581,4 +607,232 @@
onClick={() => onClickRemoveRecipient(deleteValues.id)}
bind:isVisible={isDeleteAlertVisible}
></DeleteAlert>
{#if isImportResultModalVisible && importResult}
<Modal headerText="Recipient Import Summary" bind:visible={isImportResultModalVisible}>
<div class="p-6 max-h-[80vh] overflow-y-auto">
<div class="space-y-6">
<!-- Statistics Section -->
<div class="grid grid-cols-1 gap-6">
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-2">Recipients</h3>
<ul class="space-y-1">
<li>Total: {importResult.summary.total}</li>
<li>Created: {importResult.summary.created}</li>
<li>Updated: {importResult.summary.updated}</li>
<li>Failed: {importResult.summary.failed}</li>
{#if csvSkippedRows.length > 0}
<li>Skipped in CSV: {csvSkippedRows.length}</li>
{/if}
</ul>
</div>
</div>
<!-- Details Section -->
<div class="border-t pt-6">
<div class="space-y-4">
{#if importResult.createdRecipients?.length > 0}
<div
class="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
>
<details class="group">
<summary
class="cursor-pointer p-4 font-semibold text-base text-pc-darkblue dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700/50 rounded-lg transition-colors list-none flex items-center gap-2"
>
<svg
class="w-4 h-4 transition-transform group-open:rotate-90"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
<span>Created ({importResult.createdRecipients.length})</span>
</summary>
<div class="px-4 pb-4">
<div class="space-y-1">
{#each importResult.createdRecipients as recipient}
<div
class="flex items-center justify-between py-2 px-3 rounded hover:bg-white dark:hover:bg-gray-700/50 transition-colors"
>
<span
class="text-sm text-gray-900 dark:text-gray-100 font-medium truncate flex-1"
>
{recipient.email}
</span>
{#if recipient.firstName || recipient.lastName}
<span
class="text-sm text-gray-500 dark:text-gray-400 ml-4 whitespace-nowrap"
>
{recipient.firstName || ''}
{recipient.lastName || ''}
</span>
{/if}
</div>
{/each}
</div>
</div>
</details>
</div>
{/if}
{#if importResult.updatedRecipients?.length > 0}
<div
class="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
>
<details class="group">
<summary
class="cursor-pointer p-4 font-semibold text-base text-pc-darkblue dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700/50 rounded-lg transition-colors list-none flex items-center gap-2"
>
<svg
class="w-4 h-4 transition-transform group-open:rotate-90"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
<span>Updated ({importResult.updatedRecipients.length})</span>
</summary>
<div class="px-4 pb-4">
<div class="space-y-1">
{#each importResult.updatedRecipients as recipient}
<div
class="flex items-center justify-between py-2 px-3 rounded hover:bg-white dark:hover:bg-gray-700/50 transition-colors"
>
<span
class="text-sm text-gray-900 dark:text-gray-100 font-medium truncate flex-1"
>
{recipient.email}
</span>
{#if recipient.firstName || recipient.lastName}
<span
class="text-sm text-gray-500 dark:text-gray-400 ml-4 whitespace-nowrap"
>
{recipient.firstName || ''}
{recipient.lastName || ''}
</span>
{/if}
</div>
{/each}
</div>
</div>
</details>
</div>
{/if}
{#if csvSkippedRows.length > 0}
<div
class="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
>
<details class="group">
<summary
class="cursor-pointer p-4 font-semibold text-base text-pc-darkblue dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700/50 rounded-lg transition-colors list-none flex items-center gap-2"
>
<svg
class="w-4 h-4 transition-transform group-open:rotate-90"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
<span>Skipped in CSV ({csvSkippedRows.length})</span>
</summary>
<div class="px-4 pb-4">
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3">
These rows were skipped during CSV parsing (before import)
</p>
<div class="space-y-1">
{#each csvSkippedRows as skip}
<div
class="flex items-center justify-between py-2 px-3 rounded hover:bg-white dark:hover:bg-gray-700/50 transition-colors"
>
<span
class="text-sm text-gray-900 dark:text-gray-100 font-medium truncate flex-1"
>
Line {skip.line}: {skip.reason}
{#if skip.row?.email}
<span class="text-gray-500 dark:text-gray-400 ml-2"
>({skip.row.email})</span
>
{/if}
</span>
</div>
{/each}
</div>
</div>
</details>
</div>
{/if}
{#if importResult.failures?.length > 0}
<div
class="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
>
<details class="group">
<summary
class="cursor-pointer p-4 font-semibold text-base text-pc-darkblue dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700/50 rounded-lg transition-colors list-none flex items-center gap-2"
>
<svg
class="w-4 h-4 transition-transform group-open:rotate-90"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
<span>Import Errors ({importResult.failures.length})</span>
</summary>
<div class="px-4 pb-4">
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3">
These recipients failed to import (backend errors)
</p>
<div class="space-y-1">
{#each importResult.failures as err}
<div
class="flex items-center justify-between py-2 px-3 rounded hover:bg-white dark:hover:bg-gray-700/50 transition-colors"
>
<span
class="text-sm text-gray-900 dark:text-gray-100 font-medium truncate flex-1"
>
{err.email}: {err.reason}
</span>
</div>
{/each}
</div>
</div>
</details>
</div>
{/if}
</div>
</div>
<div class="mt-4 flex justify-end">
<Button on:click={() => (isImportResultModalVisible = false)}>Close</Button>
</div>
</div>
</div>
</Modal>
{/if}
</main>