mirror of
https://github.com/btouchard/ackify-ce.git
synced 2026-02-09 07:18:36 -06:00
feat(csv): import expected signature from CSV
This commit is contained in:
@@ -408,7 +408,34 @@
|
||||
"deleteItem4": "Der Erinnerungsverlauf",
|
||||
"deleteConfirmTitle": "⚠️ Löschen bestätigen",
|
||||
"deleteConfirmButton": "Endgültig löschen",
|
||||
"documentId": "Dokument-ID:"
|
||||
"documentId": "Dokument-ID:",
|
||||
"importCSV": "CSV importieren",
|
||||
"importCSVTitle": "Leser aus einer CSV-Datei importieren",
|
||||
"selectFile": "Eine CSV-Datei auswählen",
|
||||
"csvFormatHelp": "Akzeptiertes Format: 'email' und 'name' Spalten (beliebige Reihenfolge), Komma- oder Semikolon-Trennzeichen",
|
||||
"analyze": "Analysieren",
|
||||
"analyzing": "Wird analysiert...",
|
||||
"validEntries": "Gültig",
|
||||
"existingEntries": "Bereits vorhanden",
|
||||
"invalidEntries": "Ungültig",
|
||||
"lineNumber": "Zeile",
|
||||
"email": "E-Mail",
|
||||
"name": "Name",
|
||||
"statusValid": "Gültig",
|
||||
"statusExists": "Vorhanden",
|
||||
"parseErrors": "Analysefehler",
|
||||
"content": "Inhalt",
|
||||
"errorReason": "Fehler",
|
||||
"backToFileSelection": "Zurück",
|
||||
"importing": "Importieren...",
|
||||
"importButton": "{count} Leser importieren",
|
||||
"csvImportSuccess": "{imported} Leser importiert, {skipped} übersprungen",
|
||||
"filterPlaceholder": "Nach Name oder E-Mail filtern...",
|
||||
"csvError": {
|
||||
"email_required": "E-Mail erforderlich",
|
||||
"invalid_email_format": "Ungültiges E-Mail-Format",
|
||||
"max_signers_exceeded": "Limit überschritten"
|
||||
}
|
||||
},
|
||||
"documentForm": {
|
||||
"title": "Dokumentreferenz",
|
||||
|
||||
@@ -408,7 +408,34 @@
|
||||
"deleteItem4": "The reminder history",
|
||||
"deleteConfirmTitle": "⚠️ Confirm deletion",
|
||||
"deleteConfirmButton": "Delete permanently",
|
||||
"documentId": "Document ID:"
|
||||
"documentId": "Document ID:",
|
||||
"importCSV": "Import CSV",
|
||||
"importCSVTitle": "Import readers from a CSV file",
|
||||
"selectFile": "Select a CSV file",
|
||||
"csvFormatHelp": "Accepted format: 'email' and 'name' columns (any order), comma or semicolon separator",
|
||||
"analyze": "Analyze",
|
||||
"analyzing": "Analyzing...",
|
||||
"validEntries": "Valid",
|
||||
"existingEntries": "Already exist",
|
||||
"invalidEntries": "Invalid",
|
||||
"lineNumber": "Line",
|
||||
"email": "Email",
|
||||
"name": "Name",
|
||||
"statusValid": "Valid",
|
||||
"statusExists": "Existing",
|
||||
"parseErrors": "Parse errors",
|
||||
"content": "Content",
|
||||
"errorReason": "Error",
|
||||
"backToFileSelection": "Back",
|
||||
"importing": "Importing...",
|
||||
"importButton": "Import {count} reader(s)",
|
||||
"csvImportSuccess": "{imported} reader(s) imported, {skipped} skipped",
|
||||
"filterPlaceholder": "Filter by name or email...",
|
||||
"csvError": {
|
||||
"email_required": "Email required",
|
||||
"invalid_email_format": "Invalid email format",
|
||||
"max_signers_exceeded": "Limit exceeded"
|
||||
}
|
||||
},
|
||||
"documentForm": {
|
||||
"title": "Document reference",
|
||||
|
||||
@@ -408,7 +408,34 @@
|
||||
"deleteItem4": "El historial de recordatorios",
|
||||
"deleteConfirmTitle": "⚠️ Confirmar la eliminación",
|
||||
"deleteConfirmButton": "Eliminar definitivamente",
|
||||
"documentId": "ID del documento:"
|
||||
"documentId": "ID del documento:",
|
||||
"importCSV": "Importar CSV",
|
||||
"importCSVTitle": "Importar lectores desde un archivo CSV",
|
||||
"selectFile": "Seleccionar un archivo CSV",
|
||||
"csvFormatHelp": "Formato aceptado: columnas 'email' y 'name' (orden libre), separador coma o punto y coma",
|
||||
"analyze": "Analizar",
|
||||
"analyzing": "Analizando...",
|
||||
"validEntries": "Válidos",
|
||||
"existingEntries": "Ya existen",
|
||||
"invalidEntries": "Inválidos",
|
||||
"lineNumber": "Línea",
|
||||
"email": "Email",
|
||||
"name": "Nombre",
|
||||
"statusValid": "Válido",
|
||||
"statusExists": "Existente",
|
||||
"parseErrors": "Errores de análisis",
|
||||
"content": "Contenido",
|
||||
"errorReason": "Error",
|
||||
"backToFileSelection": "Volver",
|
||||
"importing": "Importando...",
|
||||
"importButton": "Importar {count} lector(es)",
|
||||
"csvImportSuccess": "{imported} lector(es) importado(s), {skipped} ignorado(s)",
|
||||
"filterPlaceholder": "Filtrar por nombre o email...",
|
||||
"csvError": {
|
||||
"email_required": "Email requerido",
|
||||
"invalid_email_format": "Formato de email inválido",
|
||||
"max_signers_exceeded": "Límite excedido"
|
||||
}
|
||||
},
|
||||
"documentForm": {
|
||||
"title": "Referencia del documento",
|
||||
|
||||
@@ -405,7 +405,34 @@
|
||||
"deleteItem4": "L'historique des relances",
|
||||
"deleteConfirmTitle": "⚠️ Confirmer la suppression",
|
||||
"deleteConfirmButton": "Supprimer définitivement",
|
||||
"documentId": "Document ID:"
|
||||
"documentId": "Document ID:",
|
||||
"importCSV": "Import CSV",
|
||||
"importCSVTitle": "Importer des lecteurs depuis un fichier CSV",
|
||||
"selectFile": "Sélectionner un fichier CSV",
|
||||
"csvFormatHelp": "Format accepté : colonnes 'email' et 'name' (ordre libre), séparateur virgule ou point-virgule",
|
||||
"analyze": "Analyser",
|
||||
"analyzing": "Analyse...",
|
||||
"validEntries": "Valides",
|
||||
"existingEntries": "Déjà présents",
|
||||
"invalidEntries": "Invalides",
|
||||
"lineNumber": "Ligne",
|
||||
"email": "Email",
|
||||
"name": "Nom",
|
||||
"statusValid": "Valide",
|
||||
"statusExists": "Existant",
|
||||
"parseErrors": "Erreurs de parsing",
|
||||
"content": "Contenu",
|
||||
"errorReason": "Erreur",
|
||||
"backToFileSelection": "Retour",
|
||||
"importing": "Import...",
|
||||
"importButton": "Importer {count} lecteur(s)",
|
||||
"csvImportSuccess": "{imported} lecteur(s) importé(s), {skipped} ignoré(s)",
|
||||
"filterPlaceholder": "Filtrer par nom ou email...",
|
||||
"csvError": {
|
||||
"email_required": "Email requis",
|
||||
"invalid_email_format": "Format email invalide",
|
||||
"max_signers_exceeded": "Limite dépassée"
|
||||
}
|
||||
},
|
||||
"documentForm": {
|
||||
"title": "Référence du document",
|
||||
|
||||
@@ -408,7 +408,34 @@
|
||||
"deleteItem4": "La cronologia dei promemoria",
|
||||
"deleteConfirmTitle": "⚠️ Conferma eliminazione",
|
||||
"deleteConfirmButton": "Elimina definitivamente",
|
||||
"documentId": "ID Documento:"
|
||||
"documentId": "ID Documento:",
|
||||
"importCSV": "Importa CSV",
|
||||
"importCSVTitle": "Importa lettori da un file CSV",
|
||||
"selectFile": "Seleziona un file CSV",
|
||||
"csvFormatHelp": "Formato accettato: colonne 'email' e 'name' (ordine libero), separatore virgola o punto e virgola",
|
||||
"analyze": "Analizza",
|
||||
"analyzing": "Analisi in corso...",
|
||||
"validEntries": "Validi",
|
||||
"existingEntries": "Già presenti",
|
||||
"invalidEntries": "Non validi",
|
||||
"lineNumber": "Riga",
|
||||
"email": "Email",
|
||||
"name": "Nome",
|
||||
"statusValid": "Valido",
|
||||
"statusExists": "Esistente",
|
||||
"parseErrors": "Errori di analisi",
|
||||
"content": "Contenuto",
|
||||
"errorReason": "Errore",
|
||||
"backToFileSelection": "Indietro",
|
||||
"importing": "Importazione...",
|
||||
"importButton": "Importa {count} lettore/i",
|
||||
"csvImportSuccess": "{imported} lettore/i importato/i, {skipped} saltato/i",
|
||||
"filterPlaceholder": "Filtra per nome o email...",
|
||||
"csvError": {
|
||||
"email_required": "Email richiesta",
|
||||
"invalid_email_format": "Formato email non valido",
|
||||
"max_signers_exceeded": "Limite superato"
|
||||
}
|
||||
},
|
||||
"documentForm": {
|
||||
"title": "Riferimento del documento",
|
||||
|
||||
@@ -11,7 +11,11 @@ import {
|
||||
removeExpectedSigner,
|
||||
sendReminders,
|
||||
deleteDocument,
|
||||
previewCSVSigners,
|
||||
importSigners,
|
||||
type DocumentStatus,
|
||||
type CSVPreviewResult,
|
||||
type CSVSignerEntry,
|
||||
} from '@/services/admin'
|
||||
import { extractError } from '@/services/http'
|
||||
import {
|
||||
@@ -26,6 +30,11 @@ import {
|
||||
Clock,
|
||||
X,
|
||||
Trash2,
|
||||
Upload,
|
||||
AlertTriangle,
|
||||
FileCheck,
|
||||
FileX,
|
||||
Search,
|
||||
} from 'lucide-vue-next'
|
||||
import Card from '@/components/ui/Card.vue'
|
||||
import CardHeader from '@/components/ui/CardHeader.vue'
|
||||
@@ -64,9 +73,17 @@ const showDeleteConfirmModal = ref(false)
|
||||
const showMetadataWarningModal = ref(false)
|
||||
const showRemoveSignerModal = ref(false)
|
||||
const showSendRemindersModal = ref(false)
|
||||
const showImportCSVModal = ref(false)
|
||||
const signerToRemove = ref('')
|
||||
const remindersMessage = ref('')
|
||||
|
||||
// CSV Import
|
||||
const csvFile = ref<File | null>(null)
|
||||
const csvPreview = ref<CSVPreviewResult | null>(null)
|
||||
const analyzingCSV = ref(false)
|
||||
const importingCSV = ref(false)
|
||||
const csvError = ref('')
|
||||
|
||||
// Metadata form
|
||||
const metadataForm = ref<Partial<{
|
||||
title: string
|
||||
@@ -93,6 +110,7 @@ const savingMetadata = ref(false)
|
||||
// Expected signers form
|
||||
const signersEmails = ref('')
|
||||
const addingSigners = ref(false)
|
||||
const signerFilter = ref('')
|
||||
|
||||
// Reminders
|
||||
const sendMode = ref<'all' | 'selected'>('all')
|
||||
@@ -112,6 +130,15 @@ const stats = computed(() => documentStatus.value?.stats)
|
||||
const reminderStats = computed(() => documentStatus.value?.reminderStats)
|
||||
const smtpEnabled = computed(() => (window as any).ACKIFY_SMTP_ENABLED || false)
|
||||
const expectedSigners = computed(() => documentStatus.value?.expectedSigners || [])
|
||||
const filteredSigners = computed(() => {
|
||||
const filter = signerFilter.value.toLowerCase().trim()
|
||||
if (!filter) return expectedSigners.value
|
||||
return expectedSigners.value.filter(signer =>
|
||||
signer.email.toLowerCase().includes(filter) ||
|
||||
(signer.name && signer.name.toLowerCase().includes(filter)) ||
|
||||
(signer.userName && signer.userName.toLowerCase().includes(filter))
|
||||
)
|
||||
})
|
||||
const unexpectedSignatures = computed(() => documentStatus.value?.unexpectedSignatures || [])
|
||||
const documentMetadata = computed(() => documentStatus.value?.document)
|
||||
|
||||
@@ -355,6 +382,90 @@ async function handleDeleteDocument() {
|
||||
}
|
||||
}
|
||||
|
||||
// CSV Import functions
|
||||
function openImportCSVModal() {
|
||||
csvFile.value = null
|
||||
csvPreview.value = null
|
||||
csvError.value = ''
|
||||
showImportCSVModal.value = true
|
||||
}
|
||||
|
||||
function handleCSVFileChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (target.files && target.files[0]) {
|
||||
csvFile.value = target.files[0]
|
||||
csvPreview.value = null
|
||||
csvError.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function analyzeCSV() {
|
||||
if (!csvFile.value) return
|
||||
|
||||
try {
|
||||
analyzingCSV.value = true
|
||||
csvError.value = ''
|
||||
const response = await previewCSVSigners(docId.value, csvFile.value)
|
||||
csvPreview.value = response.data
|
||||
} catch (err) {
|
||||
csvError.value = extractError(err)
|
||||
console.error('Failed to analyze CSV:', err)
|
||||
} finally {
|
||||
analyzingCSV.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getSignerStatus(signer: CSVSignerEntry): 'valid' | 'exists' {
|
||||
if (!csvPreview.value) return 'valid'
|
||||
return csvPreview.value.existingEmails.includes(signer.email) ? 'exists' : 'valid'
|
||||
}
|
||||
|
||||
const signersToImport = computed(() => {
|
||||
if (!csvPreview.value) return []
|
||||
return csvPreview.value.signers.filter(
|
||||
s => !csvPreview.value!.existingEmails.includes(s.email)
|
||||
)
|
||||
})
|
||||
|
||||
async function confirmImportCSV() {
|
||||
if (!csvPreview.value || signersToImport.value.length === 0) return
|
||||
|
||||
try {
|
||||
importingCSV.value = true
|
||||
csvError.value = ''
|
||||
|
||||
const signersData = signersToImport.value.map(s => ({
|
||||
email: s.email,
|
||||
name: s.name
|
||||
}))
|
||||
|
||||
const response = await importSigners(docId.value, signersData)
|
||||
|
||||
showImportCSVModal.value = false
|
||||
csvFile.value = null
|
||||
csvPreview.value = null
|
||||
|
||||
success.value = t('admin.documentDetail.csvImportSuccess', {
|
||||
imported: response.data.imported,
|
||||
skipped: response.data.skipped
|
||||
})
|
||||
await loadDocumentStatus()
|
||||
setTimeout(() => (success.value = ''), 3000)
|
||||
} catch (err) {
|
||||
csvError.value = extractError(err)
|
||||
console.error('Failed to import signers:', err)
|
||||
} finally {
|
||||
importingCSV.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeImportCSVModal() {
|
||||
showImportCSVModal.value = false
|
||||
csvFile.value = null
|
||||
csvPreview.value = null
|
||||
csvError.value = ''
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDocumentStatus()
|
||||
})
|
||||
@@ -524,15 +635,34 @@ onMounted(() => {
|
||||
<CardTitle>{{ t('admin.documentDetail.readers') }}</CardTitle>
|
||||
<CardDescription v-if="stats">{{ stats.signedCount }} / {{ stats.expectedCount }} {{ t('admin.dashboard.stats.signed').toLowerCase() }}</CardDescription>
|
||||
</div>
|
||||
<Button @click="showAddSignersModal = true" size="sm">
|
||||
<Plus :size="16" class="mr-2" />
|
||||
{{ t('admin.documentDetail.addButton') }}
|
||||
</Button>
|
||||
<div class="flex gap-2">
|
||||
<Button @click="openImportCSVModal" size="sm" variant="outline">
|
||||
<Upload :size="16" class="mr-2" />
|
||||
{{ t('admin.documentDetail.importCSV') }}
|
||||
</Button>
|
||||
<Button @click="showAddSignersModal = true" size="sm">
|
||||
<Plus :size="16" class="mr-2" />
|
||||
{{ t('admin.documentDetail.addButton') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<!-- Expected Signers Table -->
|
||||
<!-- Filter + Expected Signers Table -->
|
||||
<div v-if="expectedSigners.length > 0">
|
||||
<!-- Filter -->
|
||||
<div class="relative mb-4">
|
||||
<Search :size="16" class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground z-10 pointer-events-none" />
|
||||
<Input
|
||||
v-model="signerFilter"
|
||||
:placeholder="t('admin.documentDetail.filterPlaceholder')"
|
||||
class="pl-9"
|
||||
name="ackify-signer-filter"
|
||||
autocomplete="off"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
/>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -547,7 +677,7 @@ onMounted(() => {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="signer in expectedSigners" :key="signer.email">
|
||||
<TableRow v-for="signer in filteredSigners" :key="signer.email">
|
||||
<TableCell>
|
||||
<input v-if="!signer.hasSigned" type="checkbox" class="rounded"
|
||||
:checked="selectedEmails.includes(signer.email)"
|
||||
@@ -731,6 +861,154 @@ onMounted(() => {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Import CSV Modal -->
|
||||
<div v-if="showImportCSVModal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" @click.self="closeImportCSVModal">
|
||||
<Card class="max-w-3xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<CardTitle>{{ t('admin.documentDetail.importCSVTitle') }}</CardTitle>
|
||||
<Button variant="ghost" size="icon" @click="closeImportCSVModal">
|
||||
<X :size="20" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="flex-1 overflow-auto">
|
||||
<!-- Error Alert -->
|
||||
<Alert v-if="csvError" variant="destructive" class="mb-4">
|
||||
<AlertDescription>{{ csvError }}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<!-- Step 1: File Selection -->
|
||||
<div v-if="!csvPreview" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">{{ t('admin.documentDetail.selectFile') }}</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv"
|
||||
@change="handleCSVFileChange"
|
||||
class="block w-full text-sm text-muted-foreground
|
||||
file:mr-4 file:py-2 file:px-4
|
||||
file:rounded-md file:border-0
|
||||
file:text-sm file:font-medium
|
||||
file:bg-primary file:text-primary-foreground
|
||||
hover:file:bg-primary/90
|
||||
cursor-pointer"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground mt-2">
|
||||
{{ t('admin.documentDetail.csvFormatHelp') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<Button type="button" variant="outline" @click="closeImportCSVModal">
|
||||
{{ t('common.cancel') }}
|
||||
</Button>
|
||||
<Button @click="analyzeCSV" :disabled="!csvFile || analyzingCSV">
|
||||
<Loader2 v-if="analyzingCSV" :size="16" class="mr-2 animate-spin" />
|
||||
{{ analyzingCSV ? t('admin.documentDetail.analyzing') : t('admin.documentDetail.analyze') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Preview -->
|
||||
<div v-else class="space-y-4">
|
||||
<!-- Summary -->
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<div class="bg-green-50 dark:bg-green-900/20 rounded-lg p-3 flex items-center gap-3">
|
||||
<FileCheck :size="24" class="text-green-600" />
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">{{ t('admin.documentDetail.validEntries') }}</p>
|
||||
<p class="text-xl font-bold text-green-600">{{ signersToImport.length }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="csvPreview.existingEmails.length > 0" class="bg-orange-50 dark:bg-orange-900/20 rounded-lg p-3 flex items-center gap-3">
|
||||
<AlertTriangle :size="24" class="text-orange-600" />
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">{{ t('admin.documentDetail.existingEntries') }}</p>
|
||||
<p class="text-xl font-bold text-orange-600">{{ csvPreview.existingEmails.length }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="csvPreview.invalidCount > 0" class="bg-red-50 dark:bg-red-900/20 rounded-lg p-3 flex items-center gap-3">
|
||||
<FileX :size="24" class="text-red-600" />
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">{{ t('admin.documentDetail.invalidEntries') }}</p>
|
||||
<p class="text-xl font-bold text-red-600">{{ csvPreview.invalidCount }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview Table -->
|
||||
<div class="border rounded-lg overflow-hidden">
|
||||
<div class="max-h-64 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-16">{{ t('admin.documentDetail.lineNumber') }}</TableHead>
|
||||
<TableHead>{{ t('admin.documentDetail.email') }}</TableHead>
|
||||
<TableHead>{{ t('admin.documentDetail.name') }}</TableHead>
|
||||
<TableHead class="w-32">{{ t('admin.documentDetail.status') }}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="signer in csvPreview.signers" :key="signer.lineNumber" :class="getSignerStatus(signer) === 'exists' ? 'bg-orange-50/50 dark:bg-orange-900/10' : ''">
|
||||
<TableCell class="text-muted-foreground">{{ signer.lineNumber }}</TableCell>
|
||||
<TableCell>{{ signer.email }}</TableCell>
|
||||
<TableCell>{{ signer.name || '-' }}</TableCell>
|
||||
<TableCell>
|
||||
<Badge :variant="getSignerStatus(signer) === 'exists' ? 'secondary' : 'default'">
|
||||
{{ getSignerStatus(signer) === 'exists' ? t('admin.documentDetail.statusExists') : t('admin.documentDetail.statusValid') }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Errors Table -->
|
||||
<div v-if="csvPreview.errors.length > 0" class="border border-destructive rounded-lg overflow-hidden">
|
||||
<div class="bg-destructive/10 px-4 py-2 font-medium text-destructive">
|
||||
{{ t('admin.documentDetail.parseErrors') }}
|
||||
</div>
|
||||
<div class="max-h-32 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-16">{{ t('admin.documentDetail.lineNumber') }}</TableHead>
|
||||
<TableHead>{{ t('admin.documentDetail.content') }}</TableHead>
|
||||
<TableHead>{{ t('admin.documentDetail.errorReason') }}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="err in csvPreview.errors" :key="err.lineNumber" class="bg-red-50/50 dark:bg-red-900/10">
|
||||
<TableCell class="text-muted-foreground">{{ err.lineNumber }}</TableCell>
|
||||
<TableCell class="font-mono text-xs truncate max-w-48">{{ err.content }}</TableCell>
|
||||
<TableCell class="text-destructive text-sm">{{ t('admin.documentDetail.csvError.' + err.error, err.error) }}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-between items-center pt-4">
|
||||
<Button type="button" variant="ghost" @click="csvPreview = null; csvFile = null">
|
||||
{{ t('admin.documentDetail.backToFileSelection') }}
|
||||
</Button>
|
||||
<div class="flex gap-3">
|
||||
<Button type="button" variant="outline" @click="closeImportCSVModal">
|
||||
{{ t('common.cancel') }}
|
||||
</Button>
|
||||
<Button @click="confirmImportCSV" :disabled="importingCSV || signersToImport.length === 0">
|
||||
<Loader2 v-if="importingCSV" :size="16" class="mr-2 animate-spin" />
|
||||
{{ importingCSV ? t('admin.documentDetail.importing') : t('admin.documentDetail.importButton', { count: signersToImport.length }) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div v-if="showDeleteConfirmModal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" @click.self="showDeleteConfirmModal = false">
|
||||
<Card class="max-w-md w-full border-destructive">
|
||||
|
||||
@@ -140,6 +140,65 @@ export async function removeExpectedSigner(
|
||||
return response.data
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CSV IMPORT
|
||||
// ============================================================================
|
||||
|
||||
export interface CSVSignerEntry {
|
||||
lineNumber: number
|
||||
email: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface CSVParseError {
|
||||
lineNumber: number
|
||||
content: string
|
||||
error: string
|
||||
}
|
||||
|
||||
export interface CSVPreviewResult {
|
||||
signers: CSVSignerEntry[]
|
||||
errors: CSVParseError[]
|
||||
totalLines: number
|
||||
validCount: number
|
||||
invalidCount: number
|
||||
hasHeader: boolean
|
||||
existingEmails: string[]
|
||||
maxSigners: number
|
||||
}
|
||||
|
||||
export interface ImportSignersResult {
|
||||
message: string
|
||||
imported: number
|
||||
skipped: number
|
||||
total: number
|
||||
}
|
||||
|
||||
// Preview CSV file before import
|
||||
export async function previewCSVSigners(
|
||||
docId: string,
|
||||
file: File
|
||||
): Promise<ApiResponse<CSVPreviewResult>> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const response = await http.post(`/admin/documents/${docId}/signers/preview-csv`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
// Import signers after preview confirmation
|
||||
export async function importSigners(
|
||||
docId: string,
|
||||
signers: { email: string; name: string }[]
|
||||
): Promise<ApiResponse<ImportSignersResult>> {
|
||||
const response = await http.post(`/admin/documents/${docId}/signers/import`, { signers })
|
||||
return response.data
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// REMINDERS
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user