mirror of
https://github.com/btouchard/ackify.git
synced 2026-02-12 08:49:47 -06:00
- Move service initialization (MagicLink, Email, i18n) to main.go - Change signature lookup from user_sub to email for cross-auth consistency - Remove OauthService wrapper, simplify auth layer - Pass parent context to workers for graceful shutdown - Fix IP extraction from RemoteAddr with port - Add compact mode to SignatureList component - Update Cypress tests with new data-testid attributes
1123 lines
60 KiB
Vue
1123 lines
60 KiB
Vue
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { usePageTitle } from '@/composables/usePageTitle'
|
|
import { useI18n } from 'vue-i18n'
|
|
import {
|
|
getDocumentStatus,
|
|
updateDocumentMetadata,
|
|
addExpectedSigner,
|
|
removeExpectedSigner,
|
|
sendReminders,
|
|
deleteDocument,
|
|
previewCSVSigners,
|
|
importSigners,
|
|
type DocumentStatus,
|
|
type CSVPreviewResult,
|
|
type CSVSignerEntry,
|
|
} from '@/services/admin'
|
|
import { extractError } from '@/services/http'
|
|
import {
|
|
ArrowLeft,
|
|
Users,
|
|
CheckCircle,
|
|
Mail,
|
|
Plus,
|
|
Loader2,
|
|
Copy,
|
|
Clock,
|
|
X,
|
|
Trash2,
|
|
Upload,
|
|
AlertTriangle,
|
|
FileCheck,
|
|
FileX,
|
|
Search,
|
|
AlertCircle,
|
|
ChevronRight,
|
|
ExternalLink,
|
|
Check,
|
|
FileText,
|
|
Eye,
|
|
Download,
|
|
ScrollText,
|
|
ShieldCheck,
|
|
} from 'lucide-vue-next'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const { t, locale } = useI18n()
|
|
|
|
// Data
|
|
const docId = computed(() => route.params.docId as string)
|
|
usePageTitle('admin.documentDetail.title', { docId: docId.value })
|
|
const documentStatus = ref<DocumentStatus | null>(null)
|
|
const loading = ref(true)
|
|
const error = ref('')
|
|
const success = ref('')
|
|
|
|
// Modals
|
|
const showAddSignersModal = ref(false)
|
|
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
|
|
url: string
|
|
checksum: string
|
|
checksumAlgorithm: string
|
|
description: string
|
|
readMode: string
|
|
allowDownload: boolean
|
|
requireFullRead: boolean
|
|
verifyChecksum: boolean
|
|
}>>({
|
|
title: '',
|
|
url: '',
|
|
checksum: '',
|
|
checksumAlgorithm: 'SHA-256',
|
|
description: '',
|
|
readMode: 'integrated',
|
|
allowDownload: true,
|
|
requireFullRead: false,
|
|
verifyChecksum: true,
|
|
})
|
|
const originalMetadata = ref<Partial<{
|
|
title: string
|
|
url: string
|
|
checksum: string
|
|
checksumAlgorithm: string
|
|
description: string
|
|
readMode: string
|
|
allowDownload: boolean
|
|
requireFullRead: boolean
|
|
verifyChecksum: boolean
|
|
}>>({})
|
|
const savingMetadata = ref(false)
|
|
|
|
// Expected signers form
|
|
const signersEmails = ref('')
|
|
const addingSigners = ref(false)
|
|
const signerFilter = ref('')
|
|
|
|
// Reminders
|
|
const sendMode = ref<'all' | 'selected'>('all')
|
|
const selectedEmails = ref<string[]>([])
|
|
const sendingReminders = ref(false)
|
|
|
|
// Delete
|
|
const deletingDocument = ref(false)
|
|
|
|
// Computed
|
|
const shareLink = computed(() => {
|
|
if (!documentStatus.value) return ''
|
|
return documentStatus.value.shareLink
|
|
})
|
|
|
|
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)
|
|
const documentTitle = computed(() => documentMetadata.value?.title || docId.value)
|
|
const isStoredDocument = computed(() => !!documentMetadata.value?.storageKey)
|
|
|
|
// Copy feedback
|
|
const copied = ref(false)
|
|
|
|
// Methods
|
|
async function loadDocumentStatus() {
|
|
try {
|
|
loading.value = true
|
|
error.value = ''
|
|
const response = await getDocumentStatus(docId.value)
|
|
documentStatus.value = response.data
|
|
|
|
// Pre-fill metadata form if document exists
|
|
if (documentStatus.value.document) {
|
|
const doc = documentStatus.value.document
|
|
const metadata = {
|
|
title: doc.title || '',
|
|
url: doc.url || '',
|
|
checksum: doc.checksum || '',
|
|
checksumAlgorithm: doc.checksumAlgorithm || 'SHA-256',
|
|
description: doc.description || '',
|
|
readMode: doc.readMode || 'integrated',
|
|
allowDownload: doc.allowDownload ?? true,
|
|
requireFullRead: doc.requireFullRead ?? false,
|
|
verifyChecksum: doc.verifyChecksum ?? true,
|
|
}
|
|
metadataForm.value = { ...metadata }
|
|
originalMetadata.value = { ...metadata }
|
|
}
|
|
} catch (err) {
|
|
error.value = extractError(err)
|
|
console.error('Failed to load document status:', err)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function hasCriticalFieldsChanged(): boolean {
|
|
return (
|
|
metadataForm.value.url !== originalMetadata.value.url ||
|
|
metadataForm.value.checksum !== originalMetadata.value.checksum ||
|
|
metadataForm.value.checksumAlgorithm !== originalMetadata.value.checksumAlgorithm ||
|
|
metadataForm.value.description !== originalMetadata.value.description ||
|
|
metadataForm.value.readMode !== originalMetadata.value.readMode ||
|
|
metadataForm.value.requireFullRead !== originalMetadata.value.requireFullRead
|
|
)
|
|
}
|
|
|
|
function handleSaveMetadata() {
|
|
const expectedSignaturesCount = stats.value?.signedCount || 0
|
|
const unexpectedSignaturesCount = unexpectedSignatures.value?.length || 0
|
|
const totalSignatures = expectedSignaturesCount + unexpectedSignaturesCount
|
|
const hasSignatures = totalSignatures > 0
|
|
const criticalFieldsChanged = hasCriticalFieldsChanged()
|
|
|
|
if (hasSignatures && criticalFieldsChanged) {
|
|
showMetadataWarningModal.value = true
|
|
} else {
|
|
saveMetadata()
|
|
}
|
|
}
|
|
|
|
async function saveMetadata() {
|
|
try {
|
|
savingMetadata.value = true
|
|
error.value = ''
|
|
success.value = ''
|
|
showMetadataWarningModal.value = false
|
|
await updateDocumentMetadata(docId.value, metadataForm.value)
|
|
success.value = t('admin.documentDetail.metadataSaved')
|
|
await loadDocumentStatus()
|
|
setTimeout(() => (success.value = ''), 3000)
|
|
} catch (err) {
|
|
error.value = extractError(err)
|
|
console.error('Failed to save metadata:', err)
|
|
} finally {
|
|
savingMetadata.value = false
|
|
}
|
|
}
|
|
|
|
async function addSigners() {
|
|
if (!signersEmails.value.trim()) return
|
|
|
|
try {
|
|
addingSigners.value = true
|
|
error.value = ''
|
|
success.value = ''
|
|
|
|
const lines = signersEmails.value.split('\n').filter(l => l.trim())
|
|
let addedCount = 0
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim()
|
|
const match = trimmed.match(/^(.+?)\s*<(.+?)>$/)
|
|
const email = match && match[2] ? match[2].trim() : trimmed
|
|
const name = match && match[1] ? match[1].trim() : ''
|
|
|
|
try {
|
|
await addExpectedSigner(docId.value, { email, name })
|
|
addedCount++
|
|
} catch (err) {
|
|
console.error(`Failed to add ${email}:`, err)
|
|
}
|
|
}
|
|
|
|
showAddSignersModal.value = false
|
|
signersEmails.value = ''
|
|
success.value = t('admin.documentDetail.signersAdded', { count: addedCount })
|
|
await loadDocumentStatus()
|
|
setTimeout(() => (success.value = ''), 3000)
|
|
} catch (err) {
|
|
error.value = extractError(err)
|
|
console.error('Failed to add signers:', err)
|
|
} finally {
|
|
addingSigners.value = false
|
|
}
|
|
}
|
|
|
|
function confirmRemoveSigner(email: string) {
|
|
signerToRemove.value = email
|
|
showRemoveSignerModal.value = true
|
|
}
|
|
|
|
async function removeSigner() {
|
|
const email = signerToRemove.value
|
|
if (!email) return
|
|
|
|
try {
|
|
error.value = ''
|
|
success.value = ''
|
|
await removeExpectedSigner(docId.value, email)
|
|
success.value = t('admin.documentDetail.signerRemoved', { email })
|
|
showRemoveSignerModal.value = false
|
|
signerToRemove.value = ''
|
|
await loadDocumentStatus()
|
|
setTimeout(() => (success.value = ''), 3000)
|
|
} catch (err) {
|
|
error.value = extractError(err)
|
|
console.error('Failed to remove signer:', err)
|
|
}
|
|
}
|
|
|
|
function cancelRemoveSigner() {
|
|
showRemoveSignerModal.value = false
|
|
signerToRemove.value = ''
|
|
}
|
|
|
|
function confirmSendReminders() {
|
|
remindersMessage.value =
|
|
sendMode.value === 'all'
|
|
? t('admin.documentDetail.confirmSendReminders', { count: reminderStats.value?.pendingCount || 0 })
|
|
: t('admin.documentDetail.confirmSendRemindersSelected', { count: selectedEmails.value.length })
|
|
showSendRemindersModal.value = true
|
|
}
|
|
|
|
async function sendRemindersAction() {
|
|
try {
|
|
sendingReminders.value = true
|
|
error.value = ''
|
|
success.value = ''
|
|
|
|
const normalizedLocale = locale.value.split('-')[0]
|
|
console.log('Sending reminders with locale:', normalizedLocale, '(from', locale.value, ')')
|
|
|
|
const response = await sendReminders(
|
|
docId.value,
|
|
{
|
|
emails: sendMode.value === 'selected' ? selectedEmails.value : undefined,
|
|
},
|
|
normalizedLocale
|
|
)
|
|
|
|
selectedEmails.value = []
|
|
showSendRemindersModal.value = false
|
|
|
|
if (response.data.result) {
|
|
const result = response.data.result
|
|
if (result.failed > 0) {
|
|
success.value = t('admin.documentDetail.remindersSentPartial', { sent: result.successfullySent, failed: result.failed })
|
|
} else {
|
|
success.value = t('admin.documentDetail.remindersSentSuccess', { count: result.successfullySent })
|
|
}
|
|
} else {
|
|
success.value = t('admin.documentDetail.remindersSentGeneric')
|
|
}
|
|
|
|
await loadDocumentStatus()
|
|
setTimeout(() => (success.value = ''), 3000)
|
|
} catch (err) {
|
|
error.value = extractError(err)
|
|
console.error('Failed to send reminders:', err)
|
|
} finally {
|
|
sendingReminders.value = false
|
|
}
|
|
}
|
|
|
|
function cancelSendReminders() {
|
|
showSendRemindersModal.value = false
|
|
}
|
|
|
|
async function copyToClipboard() {
|
|
try {
|
|
await navigator.clipboard.writeText(shareLink.value)
|
|
copied.value = true
|
|
setTimeout(() => (copied.value = false), 2000)
|
|
} catch (err) {
|
|
console.error('Failed to copy:', err)
|
|
}
|
|
}
|
|
|
|
function formatDate(dateString: string | undefined): string {
|
|
if (!dateString) return 'N/A'
|
|
const date = new Date(dateString)
|
|
return date.toLocaleDateString('fr-FR', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
})
|
|
}
|
|
|
|
function toggleEmailSelection(email: string) {
|
|
const index = selectedEmails.value.indexOf(email)
|
|
if (index > -1) {
|
|
selectedEmails.value.splice(index, 1)
|
|
} else {
|
|
selectedEmails.value.push(email)
|
|
}
|
|
}
|
|
|
|
async function handleDeleteDocument() {
|
|
try {
|
|
deletingDocument.value = true
|
|
error.value = ''
|
|
await deleteDocument(docId.value)
|
|
showDeleteConfirmModal.value = false
|
|
router.push('/admin')
|
|
} catch (err) {
|
|
error.value = extractError(err)
|
|
console.error('Failed to delete document:', err)
|
|
showDeleteConfirmModal.value = false
|
|
} finally {
|
|
deletingDocument.value = false
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="min-h-[calc(100vh-8rem)]">
|
|
<main class="mx-auto max-w-6xl px-4 sm:px-6 py-6 sm:py-8">
|
|
<!-- Breadcrumb -->
|
|
<nav class="flex items-center gap-2 text-sm mb-6">
|
|
<router-link to="/admin" class="text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 transition-colors">
|
|
{{ t('admin.title') }}
|
|
</router-link>
|
|
<ChevronRight :size="16" class="text-slate-300 dark:text-slate-600" />
|
|
<span class="text-slate-900 dark:text-slate-100 font-medium truncate max-w-[200px]">{{ documentTitle }}</span>
|
|
</nav>
|
|
|
|
<!-- Page Header -->
|
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6 sm:mb-8">
|
|
<div class="flex items-start gap-4">
|
|
<div class="w-12 h-12 sm:w-14 sm:h-14 rounded-xl bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center flex-shrink-0">
|
|
<FileText class="w-6 h-6 sm:w-7 sm:h-7 text-blue-600 dark:text-blue-400" />
|
|
</div>
|
|
<div>
|
|
<h1 class="text-xl sm:text-2xl font-bold text-slate-900 dark:text-white">{{ documentTitle }}</h1>
|
|
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">{{ t('admin.documentDetail.subtitle') }}</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
@click="router.push('/admin')"
|
|
class="w-full sm:w-auto inline-flex items-center justify-center gap-2 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300 font-medium rounded-lg px-4 py-2.5 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors min-h-[44px]"
|
|
>
|
|
<ArrowLeft :size="18" />
|
|
{{ t('common.back') }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Alerts -->
|
|
<div v-if="error" class="mb-6 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-4">
|
|
<div class="flex items-start">
|
|
<AlertCircle :size="20" class="mr-3 mt-0.5 text-red-600 dark:text-red-400 flex-shrink-0" />
|
|
<p class="text-sm text-red-700 dark:text-red-300">{{ error }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="success" class="mb-6 bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 rounded-xl p-4">
|
|
<div class="flex items-start">
|
|
<CheckCircle :size="20" class="mr-3 mt-0.5 text-emerald-600 dark:text-emerald-400 flex-shrink-0" />
|
|
<p class="text-sm text-emerald-700 dark:text-emerald-300">{{ success }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="loading" class="flex flex-col items-center justify-center py-24">
|
|
<Loader2 :size="48" class="animate-spin text-blue-600" />
|
|
<p class="mt-4 text-slate-500 dark:text-slate-400">{{ t('common.loading') }}</p>
|
|
</div>
|
|
|
|
<div v-else-if="documentStatus" class="space-y-6">
|
|
<!-- Share Link Card -->
|
|
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6">
|
|
<h2 class="font-semibold text-slate-900 dark:text-slate-100 mb-4">{{ t('documentEdit.shareLink.title') }}</h2>
|
|
<div class="flex flex-col sm:flex-row gap-3">
|
|
<div class="flex-1 relative">
|
|
<input
|
|
type="text"
|
|
:value="shareLink"
|
|
readonly
|
|
class="w-full px-4 py-2.5 pr-10 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-slate-700 text-slate-900 dark:text-slate-100 text-sm font-mono"
|
|
/>
|
|
<a
|
|
:href="shareLink"
|
|
target="_blank"
|
|
class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
|
|
>
|
|
<ExternalLink :size="16" />
|
|
</a>
|
|
</div>
|
|
<button
|
|
@click="copyToClipboard"
|
|
class="inline-flex items-center justify-center gap-2 bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-200 font-medium rounded-lg px-4 py-2.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors"
|
|
>
|
|
<Check v-if="copied" :size="16" class="text-emerald-500" />
|
|
<Copy v-else :size="16" />
|
|
{{ copied ? t('documentEdit.shareLink.copied') : t('documentEdit.shareLink.copy') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stats Cards -->
|
|
<div v-if="stats && stats.expectedCount > 0" class="grid gap-4 grid-cols-2 lg:grid-cols-3">
|
|
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
|
<div class="flex items-center gap-3">
|
|
<div class="w-10 h-10 rounded-xl bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center flex-shrink-0">
|
|
<Users :size="20" class="text-blue-600 dark:text-blue-400" />
|
|
</div>
|
|
<div>
|
|
<p class="text-xs text-slate-500 dark:text-slate-400">{{ t('documentEdit.stats.expected') }}</p>
|
|
<p class="text-xl font-bold text-slate-900 dark:text-slate-100">{{ stats.expectedCount }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
|
<div class="flex items-center gap-3">
|
|
<div class="w-10 h-10 rounded-xl bg-emerald-50 dark:bg-emerald-900/30 flex items-center justify-center flex-shrink-0">
|
|
<CheckCircle :size="20" class="text-emerald-600 dark:text-emerald-400" />
|
|
</div>
|
|
<div>
|
|
<p class="text-xs text-slate-500 dark:text-slate-400">{{ t('documentEdit.stats.confirmed') }}</p>
|
|
<p class="text-xl font-bold text-slate-900 dark:text-slate-100">{{ stats.signedCount }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
|
<div class="flex items-center gap-3">
|
|
<div class="w-10 h-10 rounded-xl bg-amber-50 dark:bg-amber-900/30 flex items-center justify-center flex-shrink-0">
|
|
<Clock :size="20" class="text-amber-600 dark:text-amber-400" />
|
|
</div>
|
|
<div>
|
|
<p class="text-xs text-slate-500 dark:text-slate-400">{{ t('documentEdit.stats.pending') }}</p>
|
|
<p class="text-xl font-bold text-slate-900 dark:text-slate-100">{{ stats.pendingCount }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Document Metadata -->
|
|
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700">
|
|
<div class="p-6 border-b border-slate-100 dark:border-slate-700">
|
|
<h2 class="font-semibold text-slate-900 dark:text-slate-100">{{ t('admin.documentDetail.metadata') }}</h2>
|
|
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">{{ t('admin.documentDetail.metadataDescription') }}</p>
|
|
</div>
|
|
<div class="p-6">
|
|
<form @submit.prevent="handleSaveMetadata" class="space-y-4">
|
|
<div :class="['grid gap-4', isStoredDocument ? 'grid-cols-1' : 'grid-cols-1 md:grid-cols-2']">
|
|
<div>
|
|
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">{{ t('admin.documentDetail.titleLabel') }}</label>
|
|
<input v-model="metadataForm.title" data-testid="document-title-input" :placeholder="t('admin.documentDetail.titlePlaceholder')" class="w-full px-4 py-2.5 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
|
|
</div>
|
|
<div v-if="!isStoredDocument">
|
|
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">{{ t('admin.documentDetail.urlLabel') }}</label>
|
|
<input v-model="metadataForm.url" type="url" :placeholder="t('admin.documentDetail.urlPlaceholder')" class="w-full px-4 py-2.5 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-[1fr_auto] gap-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">{{ t('admin.documentDetail.checksumLabel') }}</label>
|
|
<input v-model="metadataForm.checksum" :placeholder="t('admin.documentDetail.checksumPlaceholder')" class="w-full px-4 py-2.5 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
|
|
</div>
|
|
<div class="md:min-w-[140px]">
|
|
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">{{ t('admin.documentDetail.algorithmLabel') }}</label>
|
|
<select v-model="metadataForm.checksumAlgorithm" class="w-full px-4 py-2.5 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
|
<option value="SHA-256">SHA-256</option>
|
|
<option value="SHA-512">SHA-512</option>
|
|
<option value="MD5">MD5</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">{{ t('admin.documentDetail.descriptionLabel') }}</label>
|
|
<textarea v-model="metadataForm.description" rows="4" :placeholder="t('admin.documentDetail.descriptionPlaceholder')" class="w-full px-4 py-2.5 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"></textarea>
|
|
</div>
|
|
|
|
<!-- Reader Options -->
|
|
<div class="pt-4 border-t border-slate-100 dark:border-slate-700">
|
|
<h3 class="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">{{ t('documentCreateForm.readMode.label') }}</h3>
|
|
|
|
<!-- Read mode -->
|
|
<div class="flex gap-4 mb-4">
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input type="radio" v-model="metadataForm.readMode" value="integrated" class="w-4 h-4 text-blue-600 border-slate-300 focus:ring-blue-500" />
|
|
<Eye class="w-4 h-4 text-slate-500" />
|
|
<span class="text-sm text-slate-700 dark:text-slate-300">{{ t('documentCreateForm.readMode.integrated') }}</span>
|
|
</label>
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input type="radio" v-model="metadataForm.readMode" value="external" class="w-4 h-4 text-blue-600 border-slate-300 focus:ring-blue-500" />
|
|
<ExternalLink class="w-4 h-4 text-slate-500" />
|
|
<span class="text-sm text-slate-700 dark:text-slate-300">{{ t('documentCreateForm.readMode.external') }}</span>
|
|
</label>
|
|
</div>
|
|
|
|
<!-- Integrated mode options -->
|
|
<div v-if="metadataForm.readMode === 'integrated'" class="pl-4 border-l-2 border-blue-200 dark:border-blue-800 space-y-3 mb-4">
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input type="checkbox" v-model="metadataForm.allowDownload" class="w-4 h-4 text-blue-600 border-slate-300 rounded focus:ring-blue-500" />
|
|
<Download class="w-4 h-4 text-slate-500" />
|
|
<span class="text-sm text-slate-700 dark:text-slate-300">{{ t('documentCreateForm.options.allowDownload') }}</span>
|
|
</label>
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input type="checkbox" v-model="metadataForm.requireFullRead" class="w-4 h-4 text-blue-600 border-slate-300 rounded focus:ring-blue-500" />
|
|
<ScrollText class="w-4 h-4 text-slate-500" />
|
|
<span class="text-sm text-slate-700 dark:text-slate-300">{{ t('documentCreateForm.options.requireFullRead') }}</span>
|
|
</label>
|
|
</div>
|
|
|
|
<!-- Verify checksum -->
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input type="checkbox" v-model="metadataForm.verifyChecksum" class="w-4 h-4 text-blue-600 border-slate-300 rounded focus:ring-blue-500" />
|
|
<ShieldCheck class="w-4 h-4 text-slate-500" />
|
|
<span class="text-sm text-slate-700 dark:text-slate-300">{{ t('documentCreateForm.options.verifyChecksum') }}</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div v-if="documentMetadata" class="text-xs text-slate-500 dark:text-slate-400 pt-2 border-t border-slate-100 dark:border-slate-700">
|
|
{{ t('admin.documentDetail.createdBy', { by: documentMetadata.createdBy, date: formatDate(documentMetadata.createdAt) }) }}
|
|
</div>
|
|
|
|
<div class="flex justify-end">
|
|
<button type="submit" :disabled="savingMetadata" class="trust-gradient text-white font-medium rounded-lg px-6 py-2.5 text-sm hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2">
|
|
<Loader2 v-if="savingMetadata" :size="16" class="animate-spin" />
|
|
{{ savingMetadata ? t('admin.documentDetail.saving') : t('common.save') }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Expected Readers -->
|
|
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700">
|
|
<div class="p-6 border-b border-slate-100 dark:border-slate-700">
|
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
<div>
|
|
<h2 class="font-semibold text-slate-900 dark:text-slate-100">{{ t('admin.documentDetail.readers') }}</h2>
|
|
<p v-if="stats" class="text-sm text-slate-500 dark:text-slate-400">{{ stats.signedCount }} / {{ stats.expectedCount }} {{ t('admin.dashboard.stats.signed').toLowerCase() }}</p>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<button @click="openImportCSVModal" class="inline-flex items-center gap-2 bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-200 font-medium rounded-lg px-3 py-2 text-sm hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors">
|
|
<Upload :size="16" />
|
|
{{ t('admin.documentDetail.importCSV') }}
|
|
</button>
|
|
<button @click="showAddSignersModal = true" data-testid="open-add-signers-btn" class="trust-gradient text-white font-medium rounded-lg px-3 py-2 text-sm hover:opacity-90 transition-opacity inline-flex items-center gap-2">
|
|
<Plus :size="16" />
|
|
{{ t('admin.documentDetail.addButton') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="p-6">
|
|
<div v-if="expectedSigners.length > 0">
|
|
<div class="relative mb-4">
|
|
<Search :size="16" class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
|
|
<input v-model="signerFilter" :placeholder="t('admin.documentDetail.filterPlaceholder')" class="w-full pl-9 pr-4 py-2.5 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" name="ackify-signer-filter" autocomplete="off" data-1p-ignore data-lpignore="true" />
|
|
</div>
|
|
|
|
<!-- Table Desktop -->
|
|
<div class="hidden md:block overflow-x-auto">
|
|
<table class="w-full">
|
|
<thead>
|
|
<tr class="border-b border-slate-100 dark:border-slate-700">
|
|
<th class="px-4 py-3 w-10">
|
|
<input type="checkbox" class="rounded border-slate-300 dark:border-slate-600" @change="(e: any) => selectedEmails = e.target.checked ? expectedSigners.filter(s => !s.hasSigned).map(s => s.email) : []" />
|
|
</th>
|
|
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">{{ t('admin.documentDetail.reader') }}</th>
|
|
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">{{ t('admin.documentDetail.status') }}</th>
|
|
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">{{ t('admin.documentDetail.confirmedOn') }}</th>
|
|
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">{{ t('common.actions') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-slate-100 dark:divide-slate-700">
|
|
<tr v-for="signer in filteredSigners" :key="signer.email" class="hover:bg-slate-50 dark:hover:bg-slate-700/50">
|
|
<td class="px-4 py-3">
|
|
<input v-if="!signer.hasSigned" type="checkbox" class="rounded border-slate-300 dark:border-slate-600" :checked="selectedEmails.includes(signer.email)" @change="toggleEmailSelection(signer.email)" />
|
|
</td>
|
|
<td class="px-4 py-3">
|
|
<div>
|
|
<p class="font-medium text-slate-900 dark:text-slate-100">{{ signer.userName || signer.name || signer.email }}</p>
|
|
<p class="text-xs text-slate-500 dark:text-slate-400">{{ signer.email }}</p>
|
|
</div>
|
|
</td>
|
|
<td class="px-4 py-3">
|
|
<span :class="['inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium rounded-full', signer.hasSigned ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' : 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-400']">
|
|
{{ signer.hasSigned ? t('admin.documentDetail.statusConfirmed') : t('admin.documentDetail.statusPending') }}
|
|
</span>
|
|
</td>
|
|
<td class="px-4 py-3 text-sm text-slate-500 dark:text-slate-400">
|
|
{{ signer.signedAt ? formatDate(signer.signedAt) : '-' }}
|
|
</td>
|
|
<td class="px-4 py-3">
|
|
<button v-if="!signer.hasSigned" @click="confirmRemoveSigner(signer.email)" class="p-1.5 rounded-md hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
|
|
<Trash2 :size="16" class="text-red-600 dark:text-red-400" />
|
|
</button>
|
|
<span v-else class="text-xs text-slate-400">-</span>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Cards Mobile -->
|
|
<div class="md:hidden space-y-3">
|
|
<div v-for="signer in filteredSigners" :key="signer.email" class="bg-slate-50 dark:bg-slate-700/50 rounded-xl p-4">
|
|
<div class="flex items-start justify-between mb-2">
|
|
<div class="flex items-start gap-3">
|
|
<input v-if="!signer.hasSigned" type="checkbox" class="mt-1 rounded border-slate-300 dark:border-slate-600" :checked="selectedEmails.includes(signer.email)" @change="toggleEmailSelection(signer.email)" />
|
|
<div>
|
|
<p class="font-medium text-slate-900 dark:text-slate-100">{{ signer.userName || signer.name || signer.email }}</p>
|
|
<p class="text-xs text-slate-500 dark:text-slate-400">{{ signer.email }}</p>
|
|
</div>
|
|
</div>
|
|
<span :class="['inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full', signer.hasSigned ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' : 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-400']">
|
|
{{ signer.hasSigned ? t('admin.documentDetail.statusConfirmed') : t('admin.documentDetail.statusPending') }}
|
|
</span>
|
|
</div>
|
|
<div class="flex items-center justify-between text-xs text-slate-500 dark:text-slate-400">
|
|
<span>{{ signer.signedAt ? formatDate(signer.signedAt) : '-' }}</span>
|
|
<button v-if="!signer.hasSigned" @click="confirmRemoveSigner(signer.email)" class="p-1 text-red-600 dark:text-red-400">
|
|
<Trash2 :size="14" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else class="text-center py-8">
|
|
<Users :size="48" class="mx-auto mb-4 text-slate-300 dark:text-slate-600" />
|
|
<p class="text-slate-500 dark:text-slate-400">{{ t('admin.documentDetail.noExpectedSigners') }}</p>
|
|
</div>
|
|
|
|
<!-- Unexpected signatures -->
|
|
<div v-if="unexpectedSignatures.length > 0" class="mt-8 pt-6 border-t border-slate-200 dark:border-slate-700">
|
|
<h3 class="text-base font-semibold mb-4 flex items-center text-slate-900 dark:text-slate-100">
|
|
<AlertTriangle :size="18" class="mr-2 text-amber-500" />
|
|
{{ t('admin.documentDetail.unexpectedSignatures') }}
|
|
<span class="ml-2 inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full bg-amber-50 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">{{ unexpectedSignatures.length }}</span>
|
|
</h3>
|
|
<p class="text-sm text-slate-500 dark:text-slate-400 mb-4">{{ t('admin.documentDetail.unexpectedDescription') }}</p>
|
|
<div class="space-y-2">
|
|
<div v-for="(sig, idx) in unexpectedSignatures" :key="idx" class="flex items-center justify-between p-3 bg-slate-50 dark:bg-slate-700/50 rounded-lg">
|
|
<div>
|
|
<p class="font-medium text-slate-900 dark:text-slate-100">{{ sig.userName || sig.userEmail }}</p>
|
|
<p class="text-xs text-slate-500 dark:text-slate-400">{{ sig.userEmail }}</p>
|
|
</div>
|
|
<span class="text-sm text-slate-500 dark:text-slate-400">{{ formatDate(sig.signedAtUTC) }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Email Reminders -->
|
|
<div v-if="reminderStats && stats && stats.expectedCount > 0 && (smtpEnabled || reminderStats.totalSent > 0)" class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700">
|
|
<div class="p-6 border-b border-slate-100 dark:border-slate-700">
|
|
<h2 class="font-semibold text-slate-900 dark:text-slate-100">{{ t('admin.documentDetail.reminders') }}</h2>
|
|
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">{{ t('admin.documentDetail.remindersDescription') }}</p>
|
|
</div>
|
|
<div class="p-6 space-y-6">
|
|
<div class="grid gap-4 grid-cols-1 sm:grid-cols-3">
|
|
<div class="bg-slate-50 dark:bg-slate-700/50 rounded-lg p-4">
|
|
<p class="text-sm text-slate-500 dark:text-slate-400">{{ t('admin.documentDetail.remindersSent') }}</p>
|
|
<p class="text-2xl font-bold text-slate-900 dark:text-slate-100">{{ reminderStats.totalSent }}</p>
|
|
</div>
|
|
<div class="bg-slate-50 dark:bg-slate-700/50 rounded-lg p-4">
|
|
<p class="text-sm text-slate-500 dark:text-slate-400">{{ t('admin.documentDetail.toRemind') }}</p>
|
|
<p class="text-2xl font-bold text-slate-900 dark:text-slate-100">{{ reminderStats.pendingCount }}</p>
|
|
</div>
|
|
<div v-if="reminderStats.lastSentAt" class="bg-slate-50 dark:bg-slate-700/50 rounded-lg p-4">
|
|
<p class="text-sm text-slate-500 dark:text-slate-400">{{ t('admin.documentDetail.lastReminder') }}</p>
|
|
<p class="text-sm font-bold text-slate-900 dark:text-slate-100">{{ formatDate(reminderStats.lastSentAt) }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="!smtpEnabled" class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-xl p-4">
|
|
<p class="text-sm text-amber-800 dark:text-amber-200">{{ t('admin.documentDetail.emailServiceDisabled') }}</p>
|
|
</div>
|
|
|
|
<div v-if="smtpEnabled" class="space-y-4">
|
|
<div class="space-y-2">
|
|
<label class="flex items-center space-x-2 cursor-pointer">
|
|
<input type="radio" v-model="sendMode" value="all" class="text-blue-600 focus:ring-blue-500" />
|
|
<span class="text-sm text-slate-700 dark:text-slate-300">{{ t('admin.documentDetail.sendToAll', { count: reminderStats.pendingCount }) }}</span>
|
|
</label>
|
|
<label class="flex items-center space-x-2 cursor-pointer">
|
|
<input type="radio" v-model="sendMode" value="selected" class="text-blue-600 focus:ring-blue-500" />
|
|
<span class="text-sm text-slate-700 dark:text-slate-300">{{ t('admin.documentDetail.sendToSelected', { count: selectedEmails.length }) }}</span>
|
|
</label>
|
|
</div>
|
|
<button @click="confirmSendReminders" :disabled="sendingReminders || (sendMode === 'selected' && selectedEmails.length === 0)" class="trust-gradient text-white font-medium rounded-lg px-4 py-2.5 text-sm hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2">
|
|
<Mail :size="16" />
|
|
{{ sendingReminders ? t('admin.documentDetail.sending') : t('admin.documentDetail.sendReminders') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Danger Zone -->
|
|
<div class="bg-white dark:bg-slate-800 rounded-xl border border-red-200 dark:border-red-800/50">
|
|
<div class="p-6 border-b border-red-100 dark:border-red-800/30">
|
|
<h2 class="font-semibold text-red-600 dark:text-red-400">{{ t('admin.documentDetail.dangerZone') }}</h2>
|
|
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">{{ t('admin.documentDetail.dangerZoneDescription') }}</p>
|
|
</div>
|
|
<div class="p-6">
|
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 p-4 bg-red-50 dark:bg-red-900/20 rounded-xl">
|
|
<div>
|
|
<h3 class="font-semibold text-slate-900 dark:text-slate-100 mb-1">{{ t('admin.documentDetail.deleteDocument') }}</h3>
|
|
<p class="text-sm text-slate-500 dark:text-slate-400">{{ t('admin.documentDetail.deleteDocumentDescription') }}</p>
|
|
</div>
|
|
<button @click="showDeleteConfirmModal = true" class="inline-flex items-center justify-center gap-2 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg px-4 py-2.5 text-sm transition-colors flex-shrink-0">
|
|
<Trash2 :size="16" />
|
|
{{ t('common.delete') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<div v-if="showAddSignersModal" data-testid="add-signers-modal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" @click.self="showAddSignersModal = false">
|
|
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 max-w-2xl w-full max-h-[90vh] overflow-auto">
|
|
<div class="p-6 border-b border-slate-100 dark:border-slate-700 flex items-center justify-between">
|
|
<h2 class="font-semibold text-slate-900 dark:text-slate-100">{{ t('admin.documentDetail.addSigners') }}</h2>
|
|
<button @click="showAddSignersModal = false" class="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">
|
|
<X :size="20" class="text-slate-400" />
|
|
</button>
|
|
</div>
|
|
<div class="p-6">
|
|
<form @submit.prevent="addSigners" class="space-y-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">{{ t('admin.documentDetail.emailsLabel') }}</label>
|
|
<textarea v-model="signersEmails" rows="8" data-testid="signers-textarea" :placeholder="t('admin.documentDetail.emailsPlaceholder')" class="w-full px-4 py-2.5 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"></textarea>
|
|
<p class="text-xs text-slate-500 dark:text-slate-400 mt-2">{{ t('admin.documentDetail.emailsHelper') }}</p>
|
|
</div>
|
|
<div class="flex justify-end space-x-3">
|
|
<button type="button" @click="showAddSignersModal = false" class="bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-200 font-medium rounded-lg px-4 py-2.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors">{{ t('common.cancel') }}</button>
|
|
<button type="submit" :disabled="addingSigners || !signersEmails.trim()" data-testid="add-signers-btn" class="trust-gradient text-white font-medium rounded-lg px-4 py-2.5 text-sm hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2">
|
|
<Loader2 v-if="addingSigners" :size="16" class="animate-spin" />
|
|
{{ addingSigners ? t('admin.documentDetail.adding') : t('admin.documentDetail.addButton') }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="showImportCSVModal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" @click.self="closeImportCSVModal">
|
|
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 max-w-3xl w-full max-h-[90vh] overflow-auto">
|
|
<div class="p-6 border-b border-slate-100 dark:border-slate-700 flex items-center justify-between">
|
|
<h2 class="font-semibold text-slate-900 dark:text-slate-100">{{ t('admin.documentDetail.importCSVTitle') }}</h2>
|
|
<button @click="closeImportCSVModal" class="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">
|
|
<X :size="20" class="text-slate-400" />
|
|
</button>
|
|
</div>
|
|
<div class="p-6">
|
|
<div v-if="csvError" class="mb-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-4">
|
|
<p class="text-sm text-red-700 dark:text-red-300">{{ csvError }}</p>
|
|
</div>
|
|
|
|
<div v-if="!csvPreview" class="space-y-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">{{ t('admin.documentDetail.selectFile') }}</label>
|
|
<input type="file" accept=".csv" @change="handleCSVFileChange" class="block w-full text-sm text-slate-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-600 hover:file:bg-blue-100 dark:file:bg-blue-900/30 dark:file:text-blue-400 cursor-pointer" />
|
|
<p class="text-xs text-slate-500 dark:text-slate-400 mt-2">{{ t('admin.documentDetail.csvFormatHelp') }}</p>
|
|
</div>
|
|
<div class="flex justify-end space-x-3">
|
|
<button type="button" @click="closeImportCSVModal" class="bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-200 font-medium rounded-lg px-4 py-2.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors">{{ t('common.cancel') }}</button>
|
|
<button @click="analyzeCSV" :disabled="!csvFile || analyzingCSV" class="trust-gradient text-white font-medium rounded-lg px-4 py-2.5 text-sm hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2">
|
|
<Loader2 v-if="analyzingCSV" :size="16" class="animate-spin" />
|
|
{{ analyzingCSV ? t('admin.documentDetail.analyzing') : t('admin.documentDetail.analyze') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else class="space-y-4">
|
|
<div class="grid gap-3 grid-cols-1 sm:grid-cols-3">
|
|
<div class="bg-emerald-50 dark:bg-emerald-900/20 rounded-xl p-4 flex items-center gap-3">
|
|
<FileCheck :size="24" class="text-emerald-600 dark:text-emerald-400" />
|
|
<div>
|
|
<p class="text-sm text-slate-500 dark:text-slate-400">{{ t('admin.documentDetail.validEntries') }}</p>
|
|
<p class="text-xl font-bold text-emerald-600 dark:text-emerald-400">{{ signersToImport.length }}</p>
|
|
</div>
|
|
</div>
|
|
<div v-if="csvPreview.existingEmails.length > 0" class="bg-amber-50 dark:bg-amber-900/20 rounded-xl p-4 flex items-center gap-3">
|
|
<AlertTriangle :size="24" class="text-amber-600 dark:text-amber-400" />
|
|
<div>
|
|
<p class="text-sm text-slate-500 dark:text-slate-400">{{ t('admin.documentDetail.existingEntries') }}</p>
|
|
<p class="text-xl font-bold text-amber-600 dark:text-amber-400">{{ csvPreview.existingEmails.length }}</p>
|
|
</div>
|
|
</div>
|
|
<div v-if="csvPreview.invalidCount > 0" class="bg-red-50 dark:bg-red-900/20 rounded-xl p-4 flex items-center gap-3">
|
|
<FileX :size="24" class="text-red-600 dark:text-red-400" />
|
|
<div>
|
|
<p class="text-sm text-slate-500 dark:text-slate-400">{{ t('admin.documentDetail.invalidEntries') }}</p>
|
|
<p class="text-xl font-bold text-red-600 dark:text-red-400">{{ csvPreview.invalidCount }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden">
|
|
<div class="max-h-64 overflow-auto">
|
|
<table class="w-full text-sm">
|
|
<thead class="bg-slate-50 dark:bg-slate-700/50">
|
|
<tr>
|
|
<th class="px-4 py-2 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">{{ t('admin.documentDetail.lineNumber') }}</th>
|
|
<th class="px-4 py-2 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">{{ t('admin.documentDetail.email') }}</th>
|
|
<th class="px-4 py-2 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">{{ t('admin.documentDetail.name') }}</th>
|
|
<th class="px-4 py-2 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">{{ t('admin.documentDetail.status') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-slate-100 dark:divide-slate-700">
|
|
<tr v-for="signer in csvPreview.signers" :key="signer.lineNumber" :class="getSignerStatus(signer) === 'exists' ? 'bg-amber-50/50 dark:bg-amber-900/10' : ''">
|
|
<td class="px-4 py-2 text-slate-500 dark:text-slate-400">{{ signer.lineNumber }}</td>
|
|
<td class="px-4 py-2 text-slate-900 dark:text-slate-100">{{ signer.email }}</td>
|
|
<td class="px-4 py-2 text-slate-500 dark:text-slate-400">{{ signer.name || '-' }}</td>
|
|
<td class="px-4 py-2">
|
|
<span :class="['inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full', getSignerStatus(signer) === 'exists' ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' : 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400']">
|
|
{{ getSignerStatus(signer) === 'exists' ? t('admin.documentDetail.statusExists') : t('admin.documentDetail.statusValid') }}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex justify-between items-center pt-4">
|
|
<button type="button" @click="csvPreview = null; csvFile = null" class="text-sm text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 transition-colors">
|
|
{{ t('admin.documentDetail.backToFileSelection') }}
|
|
</button>
|
|
<div class="flex gap-3">
|
|
<button type="button" @click="closeImportCSVModal" class="bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-200 font-medium rounded-lg px-4 py-2.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors">{{ t('common.cancel') }}</button>
|
|
<button @click="confirmImportCSV" :disabled="importingCSV || signersToImport.length === 0" class="trust-gradient text-white font-medium rounded-lg px-4 py-2.5 text-sm hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2">
|
|
<Loader2 v-if="importingCSV" :size="16" class="animate-spin" />
|
|
{{ importingCSV ? t('admin.documentDetail.importing') : t('admin.documentDetail.importButton', { count: signersToImport.length }) }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="showDeleteConfirmModal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" @click.self="showDeleteConfirmModal = false">
|
|
<div class="bg-white dark:bg-slate-800 rounded-xl border border-red-200 dark:border-red-800 max-w-md w-full">
|
|
<div class="p-6 border-b border-red-100 dark:border-red-800/30 flex items-center justify-between">
|
|
<h2 class="font-semibold text-red-600 dark:text-red-400">{{ t('admin.documentDetail.deleteConfirmTitle') }}</h2>
|
|
<button @click="showDeleteConfirmModal = false" class="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">
|
|
<X :size="20" class="text-slate-400" />
|
|
</button>
|
|
</div>
|
|
<div class="p-6 space-y-4">
|
|
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-4">
|
|
<p class="font-semibold text-red-900 dark:text-red-200 mb-2">{{ t('admin.documentDetail.deleteWarning') }}</p>
|
|
<p class="text-sm text-red-700 dark:text-red-300">{{ t('admin.documentDetail.deleteWillRemove') }}</p>
|
|
<ul class="text-sm list-disc list-inside mt-2 space-y-1 text-red-700 dark:text-red-300">
|
|
<li>{{ t('admin.documentDetail.deleteItem1') }}</li>
|
|
<li>{{ t('admin.documentDetail.deleteItem2') }}</li>
|
|
<li>{{ t('admin.documentDetail.deleteItem3') }}</li>
|
|
<li>{{ t('admin.documentDetail.deleteItem4') }}</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="bg-slate-50 dark:bg-slate-700/50 p-3 rounded-lg">
|
|
<p class="text-sm font-mono text-slate-600 dark:text-slate-400">{{ t('admin.documentDetail.documentId') }} {{ docId }}</p>
|
|
</div>
|
|
|
|
<div class="flex justify-end space-x-3 pt-4">
|
|
<button type="button" @click="showDeleteConfirmModal = false" class="bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-200 font-medium rounded-lg px-4 py-2.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors">{{ t('common.cancel') }}</button>
|
|
<button @click="handleDeleteDocument" :disabled="deletingDocument" class="bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg px-4 py-2.5 text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2">
|
|
<Trash2 v-if="!deletingDocument" :size="16" />
|
|
<Loader2 v-else :size="16" class="animate-spin" />
|
|
{{ deletingDocument ? t('admin.documentDetail.deleting') : t('admin.documentDetail.deleteConfirmButton') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Metadata Warning Modal -->
|
|
<div v-if="showMetadataWarningModal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" @click.self="showMetadataWarningModal = false">
|
|
<div class="bg-white dark:bg-slate-800 rounded-xl border border-amber-200 dark:border-amber-800 max-w-lg w-full">
|
|
<div class="p-6 border-b border-amber-100 dark:border-amber-800/30 flex items-center justify-between">
|
|
<h2 class="font-semibold text-amber-600 dark:text-amber-400">{{ t('admin.documentDetail.metadataWarning.title') }}</h2>
|
|
<button @click="showMetadataWarningModal = false" class="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">
|
|
<X :size="20" class="text-slate-400" />
|
|
</button>
|
|
</div>
|
|
<div class="p-6 space-y-4">
|
|
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-xl p-4">
|
|
<p class="text-sm text-amber-800 dark:text-amber-200 mb-3">{{ t('admin.documentDetail.metadataWarning.description') }}</p>
|
|
<p class="text-sm font-semibold text-amber-900 dark:text-amber-100">{{ t('admin.documentDetail.metadataWarning.warning') }}</p>
|
|
</div>
|
|
|
|
<div class="bg-slate-50 dark:bg-slate-700/50 p-4 rounded-lg">
|
|
<p class="text-sm font-medium mb-2 text-slate-700 dark:text-slate-300">{{ t('admin.documentDetail.metadataWarning.currentSignatures') }}</p>
|
|
<div class="flex items-center gap-2">
|
|
<CheckCircle :size="20" class="text-emerald-600 dark:text-emerald-400" />
|
|
<span class="text-lg font-bold text-slate-900 dark:text-slate-100">{{ (stats?.signedCount || 0) + (unexpectedSignatures?.length || 0) }}</span>
|
|
<span class="text-sm text-slate-500 dark:text-slate-400">signature{{ ((stats?.signedCount || 0) + (unexpectedSignatures?.length || 0)) > 1 ? 's' : '' }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex justify-end space-x-3 pt-4">
|
|
<button type="button" @click="showMetadataWarningModal = false" class="bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-200 font-medium rounded-lg px-4 py-2.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors">{{ t('admin.documentDetail.metadataWarning.cancel') }}</button>
|
|
<button @click="saveMetadata" :disabled="savingMetadata" class="bg-amber-600 hover:bg-amber-700 text-white font-medium rounded-lg px-4 py-2.5 text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2">
|
|
<Loader2 v-if="savingMetadata" :size="16" class="animate-spin" />
|
|
{{ savingMetadata ? 'Enregistrement...' : t('admin.documentDetail.metadataWarning.confirm') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="showRemoveSignerModal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" @click.self="cancelRemoveSigner">
|
|
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 max-w-md w-full">
|
|
<div class="p-6 border-b border-slate-100 dark:border-slate-700 flex items-center justify-between">
|
|
<h2 class="font-semibold text-slate-900 dark:text-slate-100">{{ t('admin.documentDetail.removeSignerTitle') }}</h2>
|
|
<button @click="cancelRemoveSigner" class="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">
|
|
<X :size="20" class="text-slate-400" />
|
|
</button>
|
|
</div>
|
|
<div class="p-6 space-y-4">
|
|
<p class="text-sm text-slate-600 dark:text-slate-400">{{ t('admin.documentDetail.removeSignerMessage', { email: signerToRemove }) }}</p>
|
|
<div class="flex justify-end space-x-3 pt-4">
|
|
<button type="button" @click="cancelRemoveSigner" class="bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-200 font-medium rounded-lg px-4 py-2.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors">{{ t('common.cancel') }}</button>
|
|
<button @click="removeSigner" class="bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg px-4 py-2.5 text-sm transition-colors">{{ t('common.delete') }}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="showSendRemindersModal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" @click.self="cancelSendReminders">
|
|
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 max-w-md w-full">
|
|
<div class="p-6 border-b border-slate-100 dark:border-slate-700 flex items-center justify-between">
|
|
<h2 class="font-semibold text-slate-900 dark:text-slate-100">{{ t('admin.documentDetail.confirmSendRemindersTitle') }}</h2>
|
|
<button @click="cancelSendReminders" class="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">
|
|
<X :size="20" class="text-slate-400" />
|
|
</button>
|
|
</div>
|
|
<div class="p-6 space-y-4">
|
|
<p class="text-sm text-slate-600 dark:text-slate-400">{{ remindersMessage }}</p>
|
|
<div class="flex justify-end space-x-3 pt-4">
|
|
<button type="button" data-testid="cancel-button" @click="cancelSendReminders" class="bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-200 font-medium rounded-lg px-4 py-2.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors">{{ t('common.cancel') }}</button>
|
|
<button data-testid="confirm-button" @click="sendRemindersAction" :disabled="sendingReminders" class="trust-gradient text-white font-medium rounded-lg px-4 py-2.5 text-sm hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2">
|
|
<Loader2 v-if="sendingReminders" :size="16" class="animate-spin" />
|
|
{{ t('common.confirm') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|