fix: user document allocation

refacto: vue components extract, sign & reminder list, align tests to new components
This commit is contained in:
Benjamin
2026-01-19 23:24:15 +01:00
parent de1d9cd5e5
commit 8a0d79ac65
27 changed files with 870 additions and 494 deletions

View File

@@ -409,8 +409,8 @@ func (s *DocumentService) FindByReference(ctx context.Context, ref string, refTy
}
// FindOrCreateDocument performs smart lookup by URL/path/reference or creates new document if not found
func (s *DocumentService) FindOrCreateDocument(ctx context.Context, ref string) (*models.Document, bool, error) {
logger.Logger.Info("Find or create document", "reference", ref)
func (s *DocumentService) FindOrCreateDocument(ctx context.Context, ref string, createdBy string) (*models.Document, bool, error) {
logger.Logger.Info("Find or create document", "reference", ref, "created_by", createdBy)
refType := detectReferenceType(ref)
logger.Logger.Debug("Reference type detected", "type", refType, "reference", ref)
@@ -426,7 +426,7 @@ func (s *DocumentService) FindOrCreateDocument(ctx context.Context, ref string)
return doc, false, nil
}
logger.Logger.Info("Document not found, creating new one", "reference", ref)
logger.Logger.Info("Document not found, creating new one", "reference", ref, "created_by", createdBy)
var title string
switch refType {
@@ -441,6 +441,7 @@ func (s *DocumentService) FindOrCreateDocument(ctx context.Context, ref string)
createReq := CreateDocumentRequest{
Reference: ref,
Title: title,
CreatedBy: createdBy,
}
if refType == ReferenceTypeReference {
@@ -449,7 +450,7 @@ func (s *DocumentService) FindOrCreateDocument(ctx context.Context, ref string)
URL: "",
}
doc, err := s.repo.Create(ctx, ref, input, "")
doc, err := s.repo.Create(ctx, ref, input, createdBy)
if err != nil {
logger.Logger.Error("Failed to create document with custom doc_id",
"doc_id", ref,
@@ -459,7 +460,8 @@ func (s *DocumentService) FindOrCreateDocument(ctx context.Context, ref string)
logger.Logger.Info("Document created with custom doc_id",
"doc_id", ref,
"title", title)
"title", title,
"created_by", createdBy)
return doc, true, nil
}

View File

@@ -105,7 +105,7 @@ func TestFindOrCreateDocument_SameReferenceTwice(t *testing.T) {
reference := "doc-123"
// First call - should create document
doc1, isNew1, err := service.FindOrCreateDocument(ctx, reference)
doc1, isNew1, err := service.FindOrCreateDocument(ctx, reference, "")
if err != nil {
t.Fatalf("First FindOrCreateDocument failed: %v", err)
}
@@ -119,7 +119,7 @@ func TestFindOrCreateDocument_SameReferenceTwice(t *testing.T) {
}
// Second call with SAME reference - should find existing document
doc2, isNew2, err := service.FindOrCreateDocument(ctx, reference)
doc2, isNew2, err := service.FindOrCreateDocument(ctx, reference, "")
if err != nil {
t.Fatalf("Second FindOrCreateDocument failed: %v", err)
}
@@ -152,7 +152,7 @@ func TestFindOrCreateDocument_URLReference(t *testing.T) {
urlRef := "https://example.com/policy.pdf"
// First call - should create document
doc1, isNew1, err := service.FindOrCreateDocument(ctx, urlRef)
doc1, isNew1, err := service.FindOrCreateDocument(ctx, urlRef, "")
if err != nil {
t.Fatalf("First FindOrCreateDocument failed: %v", err)
}
@@ -164,7 +164,7 @@ func TestFindOrCreateDocument_URLReference(t *testing.T) {
firstDocID := doc1.DocID
// Second call with SAME URL - should find existing document
doc2, isNew2, err := service.FindOrCreateDocument(ctx, urlRef)
doc2, isNew2, err := service.FindOrCreateDocument(ctx, urlRef, "")
if err != nil {
t.Fatalf("Second FindOrCreateDocument failed: %v", err)
}

View File

@@ -691,7 +691,7 @@ func TestDocumentService_FindOrCreateDocument_Found(t *testing.T) {
service := NewDocumentService(mockRepo, &mockDocExpectedSignerRepoTest{}, nil)
ctx := context.Background()
doc, created, err := service.FindOrCreateDocument(ctx, "https://example.com/existing.pdf")
doc, created, err := service.FindOrCreateDocument(ctx, "https://example.com/existing.pdf", "")
if err != nil {
t.Fatalf("FindOrCreateDocument failed: %v", err)
@@ -723,7 +723,7 @@ func TestDocumentService_FindOrCreateDocument_CreateWithURL(t *testing.T) {
service := NewDocumentService(mockRepo, &mockDocExpectedSignerRepoTest{}, nil)
ctx := context.Background()
doc, created, err := service.FindOrCreateDocument(ctx, "https://example.com/new-doc.pdf")
doc, created, err := service.FindOrCreateDocument(ctx, "https://example.com/new-doc.pdf", "")
if err != nil {
t.Fatalf("FindOrCreateDocument failed: %v", err)
@@ -759,7 +759,7 @@ func TestDocumentService_FindOrCreateDocument_CreateWithPath(t *testing.T) {
service := NewDocumentService(mockRepo, &mockDocExpectedSignerRepoTest{}, nil)
ctx := context.Background()
doc, created, err := service.FindOrCreateDocument(ctx, "/home/user/important-file.pdf")
doc, created, err := service.FindOrCreateDocument(ctx, "/home/user/important-file.pdf", "")
if err != nil {
t.Fatalf("FindOrCreateDocument failed: %v", err)
@@ -805,7 +805,7 @@ func TestDocumentService_FindOrCreateDocument_CreateWithReference(t *testing.T)
service := NewDocumentService(mockRepo, &mockDocExpectedSignerRepoTest{}, nil)
ctx := context.Background()
doc, created, err := service.FindOrCreateDocument(ctx, "company-policy-2024")
doc, created, err := service.FindOrCreateDocument(ctx, "company-policy-2024", "")
if err != nil {
t.Fatalf("FindOrCreateDocument failed: %v", err)

View File

@@ -20,7 +20,7 @@ import (
// documentService defines the interface for document operations
type documentService interface {
CreateDocument(ctx context.Context, req services.CreateDocumentRequest) (*models.Document, error)
FindOrCreateDocument(ctx context.Context, ref string) (*models.Document, bool, error)
FindOrCreateDocument(ctx context.Context, ref string, createdBy string) (*models.Document, bool, error)
FindByReference(ctx context.Context, ref string, refType string) (*models.Document, error)
List(ctx context.Context, limit, offset int) ([]*models.Document, error)
Search(ctx context.Context, query string, limit, offset int) ([]*models.Document, error)
@@ -483,7 +483,7 @@ func (h *Handler) HandleFindOrCreateDocument(w http.ResponseWriter, r *http.Requ
}
// User is authenticated, create the document
doc, isNew, err := h.documentService.FindOrCreateDocument(ctx, ref)
doc, isNew, err := h.documentService.FindOrCreateDocument(ctx, ref, user.Email)
if err != nil {
logger.Logger.Error("Failed to create document",
"reference", ref,
@@ -605,11 +605,9 @@ func (h *Handler) HandleListMyDocuments(w http.ResponseWriter, r *http.Request)
UpdatedAt: doc.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
if sigs, err := h.signatureService.GetDocumentSignatures(ctx, doc.DocID); err == nil {
dto.SignatureCount = len(sigs)
}
// Get stats which correctly calculates SignedCount as expected signers who signed
if stats, err := h.documentService.GetExpectedSignerStats(ctx, doc.DocID); err == nil {
dto.SignatureCount = stats.SignedCount
dto.ExpectedSignerCount = stats.ExpectedCount
}

View File

@@ -99,7 +99,7 @@ func (m *mockAuthorizer) CanCreateDocument(_ context.Context, userEmail string)
// Mock document service
type mockDocumentService struct {
createDocFunc func(ctx context.Context, req services.CreateDocumentRequest) (*models.Document, error)
findOrCreateDocFunc func(ctx context.Context, ref string) (*models.Document, bool, error)
findOrCreateDocFunc func(ctx context.Context, ref string, createdBy string) (*models.Document, bool, error)
findByReferenceFunc func(ctx context.Context, ref string, refType string) (*models.Document, error)
}
@@ -110,9 +110,9 @@ func (m *mockDocumentService) CreateDocument(ctx context.Context, req services.C
return testDoc, nil
}
func (m *mockDocumentService) FindOrCreateDocument(ctx context.Context, ref string) (*models.Document, bool, error) {
func (m *mockDocumentService) FindOrCreateDocument(ctx context.Context, ref string, createdBy string) (*models.Document, bool, error) {
if m.findOrCreateDocFunc != nil {
return m.findOrCreateDocFunc(ctx, ref)
return m.findOrCreateDocFunc(ctx, ref, createdBy)
}
return testDoc, true, nil
}
@@ -524,7 +524,7 @@ func TestHandler_HandleFindOrCreateDocument_CreateNew(t *testing.T) {
// Document not found - return nil, nil (not an error)
return nil, nil
},
findOrCreateDocFunc: func(ctx context.Context, ref string) (*models.Document, bool, error) {
findOrCreateDocFunc: func(ctx context.Context, ref string, createdBy string) (*models.Document, bool, error) {
assert.Equal(t, "https://example.com/new-doc.pdf", ref)
return testDoc, true, nil
},

View File

@@ -49,7 +49,7 @@ type signatureService interface {
// documentService defines document operations
type documentService interface {
CreateDocument(ctx context.Context, req services.CreateDocumentRequest) (*models.Document, error)
FindOrCreateDocument(ctx context.Context, ref string) (*models.Document, bool, error)
FindOrCreateDocument(ctx context.Context, ref string, createdBy string) (*models.Document, bool, error)
FindByReference(ctx context.Context, ref string, refType string) (*models.Document, error)
List(ctx context.Context, limit, offset int) ([]*models.Document, error)
Search(ctx context.Context, query string, limit, offset int) ([]*models.Document, error)

View File

@@ -13,7 +13,7 @@ import (
)
type docService interface {
FindOrCreateDocument(ctx context.Context, ref string) (*models.Document, bool, error)
FindOrCreateDocument(ctx context.Context, ref string, createdBy string) (*models.Document, bool, error)
}
// webhookPublisher defines minimal publish capability
@@ -60,7 +60,7 @@ func EmbedDocumentMiddleware(
// Try to create document if it doesn't exist
ctx := r.Context()
doc, isNew, err := docService.FindOrCreateDocument(ctx, docID)
doc, isNew, err := docService.FindOrCreateDocument(ctx, docID, "")
if err != nil {
logger.Logger.Error("Failed to find/create document for embed",
"doc_id", docID,

View File

@@ -7,6 +7,7 @@ export default defineConfig({
e2e: {
baseUrl: 'http://localhost:8080',
specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}',
excludeSpecPattern: ['cypress/e2e/demo.cy.ts'],
supportFile: 'cypress/support/e2e.ts',
fixturesFolder: 'cypress/fixtures',
video: false,

View File

@@ -2,7 +2,6 @@
/// <reference types="cypress" />
describe('Test 3: Admin - Expected Signers Management', () => {
const adminEmail = 'admin@test.com'
const docId = 'test-admin-doc-' + Date.now()
beforeEach(() => {
@@ -61,9 +60,9 @@ describe('Test 3: Admin - Expected Signers Management', () => {
cy.contains('Pending').should('be.visible')
// Step 8: Verify stats
cy.contains('Expected').should('be.visible')
cy.contains('Expected readers').should('be.visible')
cy.contains('3').should('be.visible') // 3 expected signers
cy.contains('Confirmed').parent().should('contain', '0') // 0 confirmed
cy.contains('Confirmed').parent().parent().should('contain', '0') // 0 confirmed
})
it('should allow admin to remove expected signer', () => {

View File

@@ -2,7 +2,6 @@
/// <reference types="cypress" />
describe('Test 4: Admin - Email Reminders', () => {
const adminEmail = 'admin@test.com'
const docId = 'test-reminders-' + Date.now()
const alice = 'alice@test.com'
const bob = 'bob@test.com'
@@ -57,8 +56,8 @@ describe('Test 4: Admin - Email Reminders', () => {
cy.url({ timeout: 10000 }).should('include', `/admin/docs/${docId}`)
// Step 7: Verify stats (1 signed, 1 pending)
cy.contains('Confirmed').parent().should('contain', '1')
cy.contains('Pending').parent().should('contain', '1')
cy.contains('Confirmed').parent().parent().should('contain', '1')
cy.contains('Pending').parent().parent().should('contain', '1')
// Step 8: Send reminders to all pending
cy.clearMailbox() // Clear previous emails

View File

@@ -2,7 +2,6 @@
/// <reference types="cypress" />
describe('Test 7: Admin - Document Deletion', () => {
const adminEmail = 'admin@test.com'
const testUser = 'deletetest@example.com'
const docId = 'doc-to-delete-' + Date.now()

View File

@@ -2,7 +2,6 @@
/// <reference types="cypress" />
describe('Test 9: Complete End-to-End Workflow', () => {
const adminEmail = 'admin@test.com'
const alice = 'alice@test.com'
const bob = 'bob@test.com'
const charlie = 'charlie@test.com'
@@ -36,8 +35,8 @@ describe('Test 9: Complete End-to-End Workflow', () => {
cy.contains(charlie).should('be.visible')
// Verify stats: 0/3 signed (0%)
cy.contains('Confirmed').parent().should('contain', '0')
cy.contains('Expected').parent().should('contain', '3')
cy.contains('Confirmed').parent().parent().should('contain', '0')
cy.contains('Expected readers').parent().parent().should('contain', '3')
// ===== STEP 3: Admin sends reminders → 3 emails sent =====
cy.log('STEP 3: Admin sends reminders to all signers')
@@ -69,8 +68,8 @@ describe('Test 9: Complete End-to-End Workflow', () => {
cy.loginAsAdmin()
cy.visit(`/admin/docs/${docId}`)
cy.contains('Confirmed', { timeout: 10000 }).parent().should('contain', '1')
cy.contains('Pending').parent().should('contain', '2')
cy.contains('Confirmed', { timeout: 10000 }).parent().parent().should('contain', '1')
cy.contains('Pending').parent().parent().should('contain', '2')
// ===== STEP 6: Bob logs in and signs =====
cy.log('STEP 6: Bob signs the document')
@@ -87,8 +86,8 @@ describe('Test 9: Complete End-to-End Workflow', () => {
cy.loginAsAdmin()
cy.visit(`/admin/docs/${docId}`)
cy.contains('Confirmed', { timeout: 10000 }).parent().should('contain', '2')
cy.contains('Pending').parent().should('contain', '1')
cy.contains('Confirmed', { timeout: 10000 }).parent().parent().should('contain', '2')
cy.contains('Pending').parent().parent().should('contain', '1')
// ===== STEP 8: Admin sends new reminder → 1 email (charlie only) =====
cy.log('STEP 8: Admin sends reminder to remaining signer')
@@ -137,8 +136,8 @@ describe('Test 9: Complete End-to-End Workflow', () => {
cy.loginAsAdmin()
cy.visit(`/admin/docs/${docId}`)
cy.contains('Confirmed', { timeout: 10000 }).parent().should('contain', '3')
cy.contains('Expected').parent().should('contain', '3')
cy.contains('Confirmed', { timeout: 10000 }).parent().parent().should('contain', '3')
cy.contains('Expected readers').parent().parent().should('contain', '3')
// All signers should show "Confirmed" status
cy.contains('tr', alice).should('contain', 'Confirmed')
@@ -146,6 +145,6 @@ describe('Test 9: Complete End-to-End Workflow', () => {
cy.contains('tr', charlie).should('contain', 'Confirmed')
// No pending signers
cy.contains('Pending').parent().should('contain', '0')
cy.contains('Pending').parent().parent().should('contain', '0')
})
})

View File

@@ -2,7 +2,6 @@
/// <reference types="cypress" />
describe('Test 10: Unexpected Signatures Tracking', () => {
const adminEmail = 'admin@test.com'
const alice = 'alice@test.com'
const bob = 'bob@test.com'
const charlie = 'charlie-unexpected@test.com' // Not in expected list
@@ -34,8 +33,8 @@ describe('Test 10: Unexpected Signatures Tracking', () => {
cy.contains(bob).should('be.visible')
// Verify initial stats
cy.contains('Expected').parent().should('contain', '2')
cy.contains('Confirmed').parent().should('contain', '0')
cy.contains('Expected readers').parent().parent().should('contain', '2')
cy.contains('Confirmed').parent().parent().should('contain', '0')
// ===== STEP 2: Charlie (not expected) accesses and signs the document =====
cy.log('STEP 2: Unexpected user (Charlie) signs the document')
@@ -60,8 +59,8 @@ describe('Test 10: Unexpected Signatures Tracking', () => {
cy.contains(bob).should('be.visible')
// Stats should show: 0/2 expected signed, but there's an unexpected signature
cy.contains('Expected').parent().should('contain', '2')
cy.contains('Confirmed').parent().should('contain', '0') // 0 expected signed
cy.contains('Expected readers').parent().parent().should('contain', '2')
cy.contains('Confirmed').parent().parent().should('contain', '0') // 0 expected signed
// Unexpected signatures section should exist
cy.contains(/Unexpected|Additional.*confirmations/, { timeout: 10000 }).should('be.visible')
@@ -90,8 +89,8 @@ describe('Test 10: Unexpected Signatures Tracking', () => {
cy.visit(`/admin/docs/${docId}`)
// Expected stats: 1/2 signed
cy.contains('Confirmed', { timeout: 10000 }).parent().should('contain', '1')
cy.contains('Expected').parent().should('contain', '2')
cy.contains('Confirmed', { timeout: 10000 }).parent().parent().should('contain', '1')
cy.contains('Expected readers').parent().parent().should('contain', '2')
// Alice should show "Confirmed" in expected section
cy.contains('tr', alice).should('contain', 'Confirmed')

View File

@@ -21,7 +21,6 @@ const PAUSE_LONG = 2500 // Pause for important moments
const PAUSE_XLONG = 3500 // Extra long for key features
describe('Ackify Demo Video', () => {
const adminEmail = 'admin@test.com'
const alice = 'alice@demo.com'
const bob = 'bob@demo.com'
let docId: string

Binary file not shown.

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM"],
"types": ["cypress", "node"],
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["**/*.ts"],
"extends": "../tsconfig.json"
}

View File

@@ -0,0 +1,136 @@
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ReminderStats } from '@/services/admin'
import { Mail } from 'lucide-vue-next'
interface Props {
reminderStats: ReminderStats
smtpEnabled: boolean
selectedEmailsCount: number
sending?: boolean
}
const props = withDefaults(defineProps<Props>(), {
sending: false,
})
const emit = defineEmits<{
(e: 'send', mode: 'all' | 'selected'): void
}>()
const { t, locale } = useI18n()
// Local state
const sendMode = ref<'all' | 'selected'>('all')
// Computed
const canSend = computed(() => {
if (props.sending) return false
if (sendMode.value === 'selected' && props.selectedEmailsCount === 0) return false
return true
})
// Methods
function formatDate(dateString: string | undefined): string {
if (!dateString) return '-'
const date = new Date(dateString)
return date.toLocaleDateString(locale.value, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
function handleSend() {
emit('send', sendMode.value)
}
</script>
<template>
<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 flex items-center gap-2">
<Mail :size="18" class="text-blue-600 dark:text-blue-400" />
{{ 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: selectedEmailsCount }) }}
</span>
</label>
</div>
<button
@click="handleSend"
:disabled="!canSend"
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"
>
{{ sending ? t('admin.documentDetail.sending') : t('admin.documentDetail.sendReminders') }}
</button>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,296 @@
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ExpectedSigner, UnexpectedSignature, DocumentStats } from '@/services/admin'
import {
Users,
CheckCircle,
Plus,
Trash2,
Upload,
AlertTriangle,
Search,
} from 'lucide-vue-next'
interface Props {
expectedSigners: ExpectedSigner[]
unexpectedSignatures: UnexpectedSignature[]
stats: DocumentStats | null
showImportCSV?: boolean
selectedEmails?: string[]
}
const props = withDefaults(defineProps<Props>(), {
showImportCSV: false,
selectedEmails: () => [],
})
const emit = defineEmits<{
(e: 'add-signer'): void
(e: 'remove-signer', email: string): void
(e: 'import-csv'): void
(e: 'selection-change', emails: string[]): void
}>()
const { t, locale } = useI18n()
// Local state
const signerFilter = ref('')
const localSelectedEmails = ref<string[]>([...props.selectedEmails])
// Computed
const filteredSigners = computed(() => {
const filter = signerFilter.value.toLowerCase().trim()
if (!filter) return props.expectedSigners
return props.expectedSigners.filter(signer =>
signer.email.toLowerCase().includes(filter) ||
(signer.name && signer.name.toLowerCase().includes(filter)) ||
(signer.userName && signer.userName.toLowerCase().includes(filter))
)
})
// Methods
function formatDate(dateString: string | undefined): string {
if (!dateString) return '-'
const date = new Date(dateString)
return date.toLocaleDateString(locale.value, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
function toggleEmailSelection(email: string) {
const index = localSelectedEmails.value.indexOf(email)
if (index > -1) {
localSelectedEmails.value.splice(index, 1)
} else {
localSelectedEmails.value.push(email)
}
emit('selection-change', [...localSelectedEmails.value])
}
function selectAllPending(checked: boolean) {
if (checked) {
localSelectedEmails.value = props.expectedSigners
.filter(s => !s.hasSigned)
.map(s => s.email)
} else {
localSelectedEmails.value = []
}
emit('selection-change', [...localSelectedEmails.value])
}
function isSelected(email: string): boolean {
return localSelectedEmails.value.includes(email)
}
</script>
<template>
<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 flex items-center gap-2">
<CheckCircle :size="18" class="text-emerald-600 dark:text-emerald-400" />
{{ 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
v-if="showImportCSV"
@click="emit('import-csv')"
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="emit('add-signer')"
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) => selectAllPending(e.target.checked)"
/>
</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="isSelected(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 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">
{{ formatDate(signer.signedAt) }}
</td>
<td class="px-4 py-3">
<button
v-if="!signer.hasSigned"
@click="emit('remove-signer', 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="isSelected(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>{{ formatDate(signer.signedAt) }}</span>
<button
v-if="!signer.hasSigned"
@click="emit('remove-signer', 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>
</template>

View File

@@ -44,6 +44,7 @@ const emit = defineEmits<{
readComplete: []
checksumMismatch: [expected: string, actual: string]
checksumVerified: []
loadError: [error: string]
}>()
const { t } = useI18n()
@@ -305,6 +306,14 @@ watch(proxyUrl, (newUrl) => {
}
}, { immediate: true })
// Watch for errors and emit loadError event
watch([error, contentError], ([newError, newContentError]) => {
const errorMsg = newError || newContentError
if (errorMsg) {
emit('loadError', errorMsg)
}
})
onMounted(() => {
setTimeout(checkContentFits, 500)
})

View File

@@ -125,10 +125,12 @@
"description": "Veuillez patienter pendant que nous préparons le document pour la signature."
},
"external": {
"title": "Document externe",
"description": "Veuillez lire le document ci-dessous avant de confirmer votre lecture.",
"openDocument": "Ouvrir le document",
"noUrl": "Aucune URL disponible pour ce document"
"title": "Externes Dokument",
"description": "Bitte lesen Sie das Dokument, bevor Sie Ihre Lesung bestätigen.",
"descriptionWithUrl": "Dieses Dokument ist nicht direkt zugänglich. Bitte öffnen Sie es über den unten stehenden Link, lesen Sie es und kehren Sie dann zurück, um Ihre Lesung zu bestätigen.",
"documentUrl": "Dokument-URL",
"openDocument": "Dokument öffnen",
"noUrl": "Keine URL für dieses Dokument verfügbar"
},
"alreadySigned": {
"title": "Lecture déjà confirmée",
@@ -580,15 +582,15 @@
"nameLabel": "Name",
"namePlaceholder": "Vollständiger Name",
"reader": "Leser",
"readers": "✓ Lecteurs attendus",
"readers": "Erwartete Leser",
"user": "Benutzer",
"status": "Status",
"statusConfirmed": "✓ Confirmé",
"statusPending": "⏳ En attente",
"statusConfirmed": "Bestätigt",
"statusPending": "Ausstehend",
"confirmedOn": "Bestätigt am",
"noExpectedSigners": "Keine erwarteten Leser",
"noSignatures": "Keine Bestätigungen",
"reminders": "📧 Relances par email",
"reminders": "E-Mail Erinnerungen",
"remindersDescription": "Envoyer des rappels aux lecteurs en attente de confirmation",
"remindersSent": "Gesendete Erinnerungen",
"toRemind": "Zu erinnern",
@@ -598,9 +600,9 @@
"sendReminders": "Envoyer les relances",
"sendToAll": "Envoyer à tous les lecteurs en attente ({count})",
"sendToSelected": "Envoyer uniquement aux sélectionnés ({count})",
"allContacted": "✓ Tous les lecteurs attendus ont été contactés ou ont confirmé",
"emailServiceDisabled": "⚠️ Le service d'envoi d'emails est actuellement désactivé. L'historique des rappels reste visible, mais l'envoi de nouveaux rappels n'est pas disponible.",
"unexpectedSignatures": "⚠ Confirmations de lecture complémentaires",
"allContacted": "Alle erwarteten Leser wurden kontaktiert oder haben bestätigt",
"emailServiceDisabled": "Der E-Mail-Dienst ist derzeit deaktiviert. Der Erinnerungsverlauf bleibt sichtbar, aber das Senden neuer Erinnerungen ist nicht verfügbar.",
"unexpectedSignatures": "Zusätzliche Lesebestätigungen",
"unexpectedDescription": "Utilisateurs ayant confirmé mais non présents dans la liste des lecteurs attendus",
"createdBy": "Créé par {by} le {date}",
"saving": "Enregistrement...",
@@ -614,18 +616,18 @@
"remindersSentGeneric": "Relances envoyées avec succès",
"confirmSendReminders": "Envoyer des relances à {count} lecteur(s) en attente de confirmation ?",
"confirmSendRemindersSelected": "Envoyer des relances à {count} lecteur(s) sélectionné(s) ?",
"confirmSendRemindersTitle": "📧 Envoyer des relances",
"removeSignerTitle": "⚠️ Retirer le lecteur attendu",
"confirmSendRemindersTitle": "Erinnerungen senden",
"removeSignerTitle": "Erwarteten Leser entfernen",
"removeSignerMessage": "Retirer {email} de la liste des lecteurs attendus ?",
"metadataWarning": {
"title": "⚠️ Attention : Invalidation des signatures",
"title": "Achtung: Signaturungültigmachung",
"description": "Vous êtes sur le point de modifier des informations critiques du document (URL, checksum, algorithme ou description).",
"warning": "Cette modification entraînera l'invalidation de toutes les signatures existantes, car elles sont liées cryptographiquement au contenu actuel du document.",
"currentSignatures": "Signatures actuelles qui seront invalidées :",
"confirm": "Je comprends, continuer",
"cancel": "Annuler"
},
"dangerZone": "⚠️ Zone de danger",
"dangerZone": "Gefahrenzone",
"dangerZoneDescription": "Irreversible Aktionen für dieses Dokument",
"deleteDocument": "Dieses Dokument löschen",
"deleteDocumentDescription": "Cette action supprimera définitivement le document, ses métadonnées, les lecteurs attendus et toutes les confirmations associées.\nCette action est irréversible.",
@@ -635,7 +637,7 @@
"deleteItem2": "Die Liste der erwarteten Leser",
"deleteItem3": "Alle kryptografischen Bestätigungen",
"deleteItem4": "Der Erinnerungsverlauf",
"deleteConfirmTitle": "⚠️ Confirmer la suppression",
"deleteConfirmTitle": "Löschen bestätigen",
"deleteConfirmButton": "Supprimer définitivement",
"documentId": "Document ID:",
"importCSV": "Import CSV",

View File

@@ -126,7 +126,9 @@
},
"external": {
"title": "External document",
"description": "Please read the document below before confirming your reading.",
"description": "Please read the document before confirming your reading.",
"descriptionWithUrl": "This document is not directly accessible. Please open it via the link below, read it, then return to confirm your reading.",
"documentUrl": "Document URL",
"openDocument": "Open document",
"noUrl": "No URL available for this document"
},
@@ -582,15 +584,15 @@
"nameLabel": "Name",
"namePlaceholder": "Full name",
"reader": "Reader",
"readers": "Expected readers",
"readers": "Expected readers",
"user": "User",
"status": "Status",
"statusConfirmed": "Confirmed",
"statusPending": "Pending",
"statusConfirmed": "Confirmed",
"statusPending": "Pending",
"confirmedOn": "Confirmed on",
"noExpectedSigners": "No expected readers",
"noSignatures": "No confirmations",
"reminders": "📧 Email reminders",
"reminders": "Email reminders",
"remindersDescription": "Send reminders to readers awaiting confirmation",
"remindersSent": "Reminders sent",
"toRemind": "To remind",
@@ -600,9 +602,9 @@
"sendReminders": "Send reminders",
"sendToAll": "Send to all pending readers ({count})",
"sendToSelected": "Send to selected only ({count})",
"allContacted": "All expected readers have been contacted or confirmed",
"emailServiceDisabled": "⚠️ The email service is currently disabled. Reminder history remains visible, but sending new reminders is not available.",
"unexpectedSignatures": "Additional reading confirmations",
"allContacted": "All expected readers have been contacted or confirmed",
"emailServiceDisabled": "The email service is currently disabled. Reminder history remains visible, but sending new reminders is not available.",
"unexpectedSignatures": "Additional reading confirmations",
"unexpectedDescription": "Users who confirmed but are not on the expected readers list",
"createdBy": "Created by {by} on {date}",
"saving": "Saving...",
@@ -616,18 +618,18 @@
"remindersSentGeneric": "Reminders sent successfully",
"confirmSendReminders": "Send reminders to {count} reader(s) awaiting confirmation?",
"confirmSendRemindersSelected": "Send reminders to {count} selected reader(s)?",
"confirmSendRemindersTitle": "📧 Send reminders",
"removeSignerTitle": "⚠️ Remove expected reader",
"confirmSendRemindersTitle": "Send reminders",
"removeSignerTitle": "Remove expected reader",
"removeSignerMessage": "Remove {email} from the expected readers list?",
"metadataWarning": {
"title": "⚠️ Warning: Signature invalidation",
"title": "Warning: Signature invalidation",
"description": "You are about to modify critical document information (URL, checksum, algorithm or description).",
"warning": "This modification will invalidate all existing signatures, as they are cryptographically linked to the current document content.",
"currentSignatures": "Current signatures that will be invalidated:",
"confirm": "I understand, continue",
"cancel": "Cancel"
},
"dangerZone": "⚠️ Danger zone",
"dangerZone": "Danger zone",
"dangerZoneDescription": "Irreversible actions on this document",
"deleteDocument": "Delete this document",
"deleteDocumentDescription": "This action will permanently delete the document, its metadata, expected readers and all associated confirmations.\nThis action is irreversible.",
@@ -637,7 +639,7 @@
"deleteItem2": "The list of expected readers",
"deleteItem3": "All cryptographic confirmations",
"deleteItem4": "The reminder history",
"deleteConfirmTitle": "⚠️ Confirm deletion",
"deleteConfirmTitle": "Confirm deletion",
"deleteConfirmButton": "Delete permanently",
"documentId": "Document ID:",
"importCSV": "Import CSV",

View File

@@ -125,10 +125,12 @@
"description": "Veuillez patienter pendant que nous préparons le document pour la signature."
},
"external": {
"title": "Document externe",
"description": "Veuillez lire le document ci-dessous avant de confirmer votre lecture.",
"openDocument": "Ouvrir le document",
"noUrl": "Aucune URL disponible pour ce document"
"title": "Documento externo",
"description": "Por favor, lea el documento antes de confirmar su lectura.",
"descriptionWithUrl": "Este documento no es accesible directamente. Ábralo a través del enlace de abajo, léalo y luego regrese para confirmar su lectura.",
"documentUrl": "URL del documento",
"openDocument": "Abrir documento",
"noUrl": "No hay URL disponible para este documento"
},
"alreadySigned": {
"title": "Lecture déjà confirmée",
@@ -580,15 +582,15 @@
"nameLabel": "Nombre",
"namePlaceholder": "Nombre completo",
"reader": "Lector",
"readers": "Lecteurs attendus",
"readers": "Lectores esperados",
"user": "Usuario",
"status": "Estado",
"statusConfirmed": "Confirmé",
"statusPending": "⏳ En attente",
"statusConfirmed": "Confirmado",
"statusPending": "Pendiente",
"confirmedOn": "Confirmado el",
"noExpectedSigners": "Ningún lector esperado",
"noSignatures": "Ninguna confirmación",
"reminders": "📧 Relances par email",
"reminders": "Recordatorios por email",
"remindersDescription": "Envoyer des rappels aux lecteurs en attente de confirmation",
"remindersSent": "Recordatorios enviados",
"toRemind": "Para recordar",
@@ -598,9 +600,9 @@
"sendReminders": "Envoyer les relances",
"sendToAll": "Envoyer à tous les lecteurs en attente ({count})",
"sendToSelected": "Envoyer uniquement aux sélectionnés ({count})",
"allContacted": "✓ Tous les lecteurs attendus ont été contactés ou ont confirmé",
"emailServiceDisabled": "⚠️ Le service d'envoi d'emails est actuellement désactivé. L'historique des rappels reste visible, mais l'envoi de nouveaux rappels n'est pas disponible.",
"unexpectedSignatures": "Confirmations de lecture complémentaires",
"allContacted": "Todos los lectores esperados han sido contactados o confirmados",
"emailServiceDisabled": "El servicio de email está desactivado. El historial de recordatorios permanece visible, pero el envío de nuevos recordatorios no está disponible.",
"unexpectedSignatures": "Confirmaciones de lectura adicionales",
"unexpectedDescription": "Utilisateurs ayant confirmé mais non présents dans la liste des lecteurs attendus",
"createdBy": "Créé par {by} le {date}",
"saving": "Enregistrement...",
@@ -614,18 +616,18 @@
"remindersSentGeneric": "Relances envoyées avec succès",
"confirmSendReminders": "Envoyer des relances à {count} lecteur(s) en attente de confirmation ?",
"confirmSendRemindersSelected": "Envoyer des relances à {count} lecteur(s) sélectionné(s) ?",
"confirmSendRemindersTitle": "📧 Envoyer des relances",
"removeSignerTitle": "⚠️ Retirer le lecteur attendu",
"confirmSendRemindersTitle": "Enviar recordatorios",
"removeSignerTitle": "Eliminar lector esperado",
"removeSignerMessage": "Retirer {email} de la liste des lecteurs attendus ?",
"metadataWarning": {
"title": "⚠️ Attention : Invalidation des signatures",
"title": "Atención: Invalidación de firmas",
"description": "Vous êtes sur le point de modifier des informations critiques du document (URL, checksum, algorithme ou description).",
"warning": "Cette modification entraînera l'invalidation de toutes les signatures existantes, car elles sont liées cryptographiquement au contenu actuel du document.",
"currentSignatures": "Signatures actuelles qui seront invalidées :",
"confirm": "Je comprends, continuer",
"cancel": "Annuler"
},
"dangerZone": "⚠️ Zone de danger",
"dangerZone": "Zona de peligro",
"dangerZoneDescription": "Acciones irreversibles sobre este documento",
"deleteDocument": "Eliminar este documento",
"deleteDocumentDescription": "Cette action supprimera définitivement le document, ses métadonnées, les lecteurs attendus et toutes les confirmations associées.\nCette action est irréversible.",
@@ -635,7 +637,7 @@
"deleteItem2": "La lista de lectores esperados",
"deleteItem3": "Todas las confirmaciones criptográficas",
"deleteItem4": "El historial de recordatorios",
"deleteConfirmTitle": "⚠️ Confirmer la suppression",
"deleteConfirmTitle": "Confirmar eliminación",
"deleteConfirmButton": "Supprimer définitivement",
"documentId": "Document ID:",
"importCSV": "Import CSV",

View File

@@ -126,7 +126,9 @@
},
"external": {
"title": "Document externe",
"description": "Veuillez lire le document ci-dessous avant de confirmer votre lecture.",
"description": "Veuillez lire le document avant de confirmer votre lecture.",
"descriptionWithUrl": "Ce document n'est pas accessible directement. Veuillez l'ouvrir via le lien ci-dessous, le lire, puis revenir confirmer votre lecture.",
"documentUrl": "Adresse du document",
"openDocument": "Ouvrir le document",
"noUrl": "Aucune URL disponible pour ce document"
},
@@ -579,15 +581,15 @@
"nameLabel": "Nom",
"namePlaceholder": "Nom complet",
"reader": "Lecteur",
"readers": "Lecteurs attendus",
"readers": "Lecteurs attendus",
"user": "Utilisateur",
"status": "Statut",
"statusConfirmed": "Confirmé",
"statusPending": "En attente",
"statusConfirmed": "Confirmé",
"statusPending": "En attente",
"confirmedOn": "Confirmé le",
"noExpectedSigners": "Aucun lecteur attendu",
"noSignatures": "Aucune confirmation",
"reminders": "📧 Relances par email",
"reminders": "Relances par email",
"remindersDescription": "Envoyer des rappels aux lecteurs en attente de confirmation",
"remindersSent": "Relances envoyées",
"toRemind": "À relancer",
@@ -597,9 +599,9 @@
"sendReminders": "Envoyer les relances",
"sendToAll": "Envoyer à tous les lecteurs en attente ({count})",
"sendToSelected": "Envoyer uniquement aux sélectionnés ({count})",
"allContacted": "Tous les lecteurs attendus ont été contactés ou ont confirmé",
"emailServiceDisabled": "⚠️ Le service d'envoi d'emails est actuellement désactivé. L'historique des rappels reste visible, mais l'envoi de nouveaux rappels n'est pas disponible.",
"unexpectedSignatures": "Confirmations de lecture complémentaires",
"allContacted": "Tous les lecteurs attendus ont été contactés ou ont confirmé",
"emailServiceDisabled": "Le service d'envoi d'emails est actuellement désactivé. L'historique des rappels reste visible, mais l'envoi de nouveaux rappels n'est pas disponible.",
"unexpectedSignatures": "Confirmations de lecture complémentaires",
"unexpectedDescription": "Utilisateurs ayant confirmé mais non présents dans la liste des lecteurs attendus",
"createdBy": "Créé par {by} le {date}",
"saving": "Enregistrement...",
@@ -613,18 +615,18 @@
"remindersSentGeneric": "Relances envoyées avec succès",
"confirmSendReminders": "Envoyer des relances à {count} lecteur(s) en attente de confirmation ?",
"confirmSendRemindersSelected": "Envoyer des relances à {count} lecteur(s) sélectionné(s) ?",
"confirmSendRemindersTitle": "📧 Envoyer des relances",
"removeSignerTitle": "⚠️ Retirer le lecteur attendu",
"confirmSendRemindersTitle": "Envoyer des relances",
"removeSignerTitle": "Retirer le lecteur attendu",
"removeSignerMessage": "Retirer {email} de la liste des lecteurs attendus ?",
"metadataWarning": {
"title": "⚠️ Attention : Invalidation des signatures",
"title": "Attention : Invalidation des signatures",
"description": "Vous êtes sur le point de modifier des informations critiques du document (URL, checksum, algorithme ou description).",
"warning": "Cette modification entraînera l'invalidation de toutes les signatures existantes, car elles sont liées cryptographiquement au contenu actuel du document.",
"currentSignatures": "Signatures actuelles qui seront invalidées :",
"confirm": "Je comprends, continuer",
"cancel": "Annuler"
},
"dangerZone": "⚠️ Zone de danger",
"dangerZone": "Zone de danger",
"dangerZoneDescription": "Actions irréversibles sur ce document",
"deleteDocument": "Supprimer ce document",
"deleteDocumentDescription": "Cette action supprimera définitivement le document, ses métadonnées, les lecteurs attendus et toutes les confirmations associées.\nCette action est irréversible.",
@@ -634,7 +636,7 @@
"deleteItem2": "La liste des lecteurs attendus",
"deleteItem3": "Toutes les confirmations cryptographiques",
"deleteItem4": "L'historique des relances",
"deleteConfirmTitle": "⚠️ Confirmer la suppression",
"deleteConfirmTitle": "Confirmer la suppression",
"deleteConfirmButton": "Supprimer définitivement",
"documentId": "Document ID:",
"importCSV": "Import CSV",

View File

@@ -125,10 +125,12 @@
"description": "Veuillez patienter pendant que nous préparons le document pour la signature."
},
"external": {
"title": "Document externe",
"description": "Veuillez lire le document ci-dessous avant de confirmer votre lecture.",
"openDocument": "Ouvrir le document",
"noUrl": "Aucune URL disponible pour ce document"
"title": "Documento esterno",
"description": "Per favore, leggi il documento prima di confermare la tua lettura.",
"descriptionWithUrl": "Questo documento non è accessibile direttamente. Aprilo tramite il link qui sotto, leggilo, poi torna per confermare la tua lettura.",
"documentUrl": "URL del documento",
"openDocument": "Apri documento",
"noUrl": "Nessun URL disponibile per questo documento"
},
"alreadySigned": {
"title": "Lecture déjà confirmée",
@@ -580,15 +582,15 @@
"nameLabel": "Nome",
"namePlaceholder": "Nome completo",
"reader": "Lettore",
"readers": "✓ Lecteurs attendus",
"readers": "Lettori previsti",
"user": "Utente",
"status": "Stato",
"statusConfirmed": "Confirmé",
"statusPending": "⏳ En attente",
"statusConfirmed": "Confermato",
"statusPending": "In attesa",
"confirmedOn": "Confermato il",
"noExpectedSigners": "Nessun lettore previsto",
"noSignatures": "Nessuna conferma",
"reminders": "📧 Relances par email",
"reminders": "Promemoria via email",
"remindersDescription": "Envoyer des rappels aux lecteurs en attente de confirmation",
"remindersSent": "Promemoria inviati",
"toRemind": "Da ricordare",
@@ -598,9 +600,9 @@
"sendReminders": "Envoyer les relances",
"sendToAll": "Envoyer à tous les lecteurs en attente ({count})",
"sendToSelected": "Envoyer uniquement aux sélectionnés ({count})",
"allContacted": "✓ Tous les lecteurs attendus ont été contactés ou ont confirmé",
"emailServiceDisabled": "⚠️ Le service d'envoi d'emails est actuellement désactivé. L'historique des rappels reste visible, mais l'envoi de nouveaux rappels n'est pas disponible.",
"unexpectedSignatures": "Confirmations de lecture complémentaires",
"allContacted": "Tutti i lettori previsti sono stati contattati o hanno confermato",
"emailServiceDisabled": "Il servizio email è disattivato. La cronologia dei promemoria rimane visibile, ma l'invio di nuovi promemoria non è disponibile.",
"unexpectedSignatures": "Conferme di lettura aggiuntive",
"unexpectedDescription": "Utilisateurs ayant confirmé mais non présents dans la liste des lecteurs attendus",
"createdBy": "Créé par {by} le {date}",
"saving": "Enregistrement...",
@@ -614,18 +616,18 @@
"remindersSentGeneric": "Relances envoyées avec succès",
"confirmSendReminders": "Envoyer des relances à {count} lecteur(s) en attente de confirmation ?",
"confirmSendRemindersSelected": "Envoyer des relances à {count} lecteur(s) sélectionné(s) ?",
"confirmSendRemindersTitle": "📧 Envoyer des relances",
"removeSignerTitle": "⚠️ Retirer le lecteur attendu",
"confirmSendRemindersTitle": "Invia promemoria",
"removeSignerTitle": "Rimuovi lettore previsto",
"removeSignerMessage": "Retirer {email} de la liste des lecteurs attendus ?",
"metadataWarning": {
"title": "⚠️ Attention : Invalidation des signatures",
"title": "Attenzione: Invalidazione delle firme",
"description": "Vous êtes sur le point de modifier des informations critiques du document (URL, checksum, algorithme ou description).",
"warning": "Cette modification entraînera l'invalidation de toutes les signatures existantes, car elles sont liées cryptographiquement au contenu actuel du document.",
"currentSignatures": "Signatures actuelles qui seront invalidées :",
"confirm": "Je comprends, continuer",
"cancel": "Annuler"
},
"dangerZone": "⚠️ Zone de danger",
"dangerZone": "Zona pericolosa",
"dangerZoneDescription": "Azioni irreversibili su questo documento",
"deleteDocument": "Elimina questo documento",
"deleteDocumentDescription": "Cette action supprimera définitivement le document, ses métadonnées, les lecteurs attendus et toutes les confirmations associées.\nCette action est irréversible.",
@@ -635,7 +637,7 @@
"deleteItem2": "L'elenco dei lettori previsti",
"deleteItem3": "Tutte le conferme crittografiche",
"deleteItem4": "La cronologia dei promemoria",
"deleteConfirmTitle": "⚠️ Confirmer la suppression",
"deleteConfirmTitle": "Conferma eliminazione",
"deleteConfirmButton": "Supprimer définitivement",
"documentId": "Document ID:",
"importCSV": "Import CSV",

View File

@@ -12,31 +12,37 @@ import {
removeExpectedSigner,
sendReminders,
deleteDocument,
previewCSVSigners,
importSigners,
type DocumentStatus,
type CSVPreviewResult,
type CSVSignerEntry,
} from '@/services/admin'
import { extractError } from '@/services/http'
import { useConfigStore } from '@/stores/config'
import SignersSection from '@/components/SignersSection.vue'
import RemindersSection from '@/components/RemindersSection.vue'
import {
ArrowLeft,
Users,
CheckCircle,
Mail,
Plus,
Loader2,
Copy,
Clock,
X,
Trash2,
Search,
AlertCircle,
AlertTriangle,
ChevronRight,
ExternalLink,
Check,
FileText,
FileCheck,
FileX,
Eye,
Download,
ScrollText,
ShieldCheck,
Users,
Clock,
} from 'lucide-vue-next'
const route = useRoute()
@@ -59,9 +65,17 @@ const showAddSignersModal = ref(false)
const showDeleteConfirmModal = 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
@@ -89,7 +103,6 @@ const savingMetadata = ref(false)
// Expected signers form
const signersEmails = ref('')
const addingSigners = ref(false)
const signerFilter = ref('')
// Reminders
const sendMode = ref<'all' | 'selected'>('all')
@@ -108,22 +121,15 @@ const shareLink = computed(() => {
return documentStatus.value.shareLink
})
const stats = computed(() => documentStatus.value?.stats)
const stats = computed(() => documentStatus.value?.stats ?? null)
const reminderStats = computed(() => documentStatus.value?.reminderStats)
const smtpEnabled = computed(() => configStore.smtpEnabled)
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 documentMetadata = computed(() => documentStatus.value?.document)
const documentTitle = computed(() => documentMetadata.value?.title || docId.value)
const isStoredDocument = computed(() => !!documentMetadata.value?.storageKey)
const unexpectedSignatures = computed(() => documentStatus.value?.unexpectedSignatures || [])
const isAdmin = computed(() => authStore.isAdmin)
// Methods
async function loadDocumentStatus() {
@@ -246,9 +252,10 @@ async function removeSigner() {
}
}
function confirmSendReminders() {
function handleReminderSend(mode: 'all' | 'selected') {
sendMode.value = mode
remindersMessage.value =
sendMode.value === 'all'
mode === 'all'
? t('documentEdit.confirmSendReminders', { count: reminderStats.value?.pendingCount || 0 })
: t('documentEdit.confirmSendRemindersSelected', { count: selectedEmails.value.length })
showSendRemindersModal.value = true
@@ -315,15 +322,6 @@ function formatDate(dateString: string | undefined): string {
})
}
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
@@ -340,6 +338,90 @@ async function handleDeleteDocument() {
}
}
// CSV Import functions (admin only)
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(async () => {
if (!authStore.initialized) {
await authStore.checkAuth()
@@ -582,146 +664,27 @@ onMounted(async () => {
</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('documentEdit.readers.title') }}</h2>
<p v-if="stats" class="text-sm text-slate-500 dark:text-slate-400">{{ stats.signedCount }} / {{ stats.expectedCount }} {{ t('documentEdit.readers.confirmed') }}</p>
</div>
<button @click="showAddSignersModal = true" 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('documentEdit.readers.add') }}
</button>
</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('documentEdit.readers.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" />
</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('documentEdit.readers.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('documentEdit.readers.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('documentEdit.readers.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('documentEdit.readers.statusConfirmed') : t('documentEdit.readers.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('documentEdit.readers.statusConfirmed') : t('documentEdit.readers.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('documentEdit.readers.noReaders') }}</p>
</div>
</div>
</div>
<SignersSection
:expected-signers="expectedSigners"
:unexpected-signatures="unexpectedSignatures"
:stats="stats"
:show-import-c-s-v="isAdmin"
:selected-emails="selectedEmails"
@add-signer="showAddSignersModal = true"
@remove-signer="confirmRemoveSigner"
@import-csv="openImportCSVModal"
@selection-change="(emails) => selectedEmails = emails"
/>
<!-- 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('documentEdit.reminders.title') }}</h2>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">{{ t('documentEdit.reminders.description') }}</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('documentEdit.reminders.sent') }}</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('documentEdit.reminders.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('documentEdit.reminders.lastSent') }}</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('documentEdit.reminders.emailDisabled') }}</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('documentEdit.reminders.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('documentEdit.reminders.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('documentEdit.reminders.sending') : t('documentEdit.reminders.send') }}
</button>
</div>
</div>
</div>
<RemindersSection
v-if="reminderStats && stats && stats.expectedCount > 0 && (smtpEnabled || reminderStats.totalSent > 0)"
:reminder-stats="reminderStats"
:smtp-enabled="smtpEnabled"
:selected-emails-count="selectedEmails.length"
:sending="sendingReminders"
@send="handleReminderSend"
/>
<!-- Danger Zone -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-red-200 dark:border-red-800/50">
@@ -840,5 +803,103 @@ onMounted(async () => {
</div>
</div>
</div>
<!-- Import CSV Modal (Admin only) -->
<div v-if="isAdmin && 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>
</template>

View File

@@ -58,6 +58,7 @@ const calculatingChecksum = ref(false)
// New state for integrated viewer
const readComplete = ref(false)
const certifyChecked = ref(false)
const documentLoadFailed = ref(false)
// Check if current user has signed this document
const userHasSigned = computed(() => {
@@ -112,6 +113,7 @@ async function handleDocumentReference(ref: string) {
needsAuth.value = false
readComplete.value = false
certifyChecked.value = false
documentLoadFailed.value = false
console.log('Loading document for reference:', ref)
@@ -195,6 +197,11 @@ function handleReadComplete() {
readComplete.value = true
}
function handleDocumentLoadError(error: string) {
console.log('Document load failed:', error)
documentLoadFailed.value = true
}
async function handleSigned() {
showSuccessMessage.value = true
errorMessage.value = null
@@ -541,7 +548,7 @@ onMounted(async () => {
<!-- Left: Document Zone (2/3) -->
<div class="lg:col-span-2 space-y-6">
<!-- Integrated Viewer -->
<div v-if="isIntegratedMode && (currentDocument.url || currentDocument.storageKey)">
<div v-if="isIntegratedMode && (currentDocument.url || currentDocument.storageKey) && !documentLoadFailed">
<DocumentViewer
:document-id="docId"
:url="currentDocument.url || ''"
@@ -554,10 +561,11 @@ onMounted(async () => {
:stored-checksum="currentDocument.checksum"
:checksum-algorithm="currentDocument.checksumAlgorithm"
@read-complete="handleReadComplete"
@load-error="handleDocumentLoadError"
/>
</div>
<!-- External Mode -->
<!-- External Mode / Load Failed -->
<div v-else class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-8 text-center">
<div class="w-16 h-16 rounded-xl bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center mx-auto mb-4">
<ExternalLink :size="32" class="text-blue-600 dark:text-blue-400" />
@@ -566,18 +574,30 @@ onMounted(async () => {
{{ t('sign.external.title') }}
</h3>
<p class="text-sm text-slate-500 dark:text-slate-400 mb-6 max-w-md mx-auto">
{{ t('sign.external.description') }}
{{ currentDocument.url ? t('sign.external.descriptionWithUrl') : t('sign.external.description') }}
</p>
<a
v-if="currentDocument.url"
:href="currentDocument.url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 trust-gradient text-white font-medium rounded-lg px-6 py-3 text-sm hover:opacity-90 transition-opacity"
>
<ExternalLink :size="18" />
{{ t('sign.external.openDocument') }}
</a>
<div v-if="currentDocument.url" class="space-y-4">
<div class="bg-slate-50 dark:bg-slate-700/50 rounded-lg p-4 max-w-lg mx-auto">
<p class="text-xs text-slate-500 dark:text-slate-400 mb-1">{{ t('sign.external.documentUrl') }}</p>
<a
:href="currentDocument.url"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-blue-600 dark:text-blue-400 hover:underline break-all"
>
{{ currentDocument.url }}
</a>
</div>
<a
:href="currentDocument.url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 trust-gradient text-white font-medium rounded-lg px-6 py-3 text-sm hover:opacity-90 transition-opacity"
>
<ExternalLink :size="18" />
{{ t('sign.external.openDocument') }}
</a>
</div>
<p v-else class="text-sm text-slate-400 dark:text-slate-500 italic">
{{ t('sign.external.noUrl') }}
</p>

View File

@@ -19,22 +19,18 @@ import {
} from '@/services/admin'
import { extractError } from '@/services/http'
import { useConfigStore } from '@/stores/config'
import SignersSection from '@/components/SignersSection.vue'
import RemindersSection from '@/components/RemindersSection.vue'
import {
ArrowLeft,
Users,
CheckCircle,
Mail,
Plus,
Loader2,
Copy,
Clock,
X,
Trash2,
Upload,
AlertTriangle,
FileCheck,
FileX,
Search,
AlertCircle,
ChevronRight,
ExternalLink,
@@ -44,6 +40,8 @@ import {
Download,
ScrollText,
ShieldCheck,
Users,
Clock,
} from 'lucide-vue-next'
const route = useRoute()
@@ -114,7 +112,6 @@ const savingMetadata = ref(false)
// Expected signers form
const signersEmails = ref('')
const addingSigners = ref(false)
const signerFilter = ref('')
// Reminders
const sendMode = ref<'all' | 'selected'>('all')
@@ -130,19 +127,10 @@ const shareLink = computed(() => {
return documentStatus.value.shareLink
})
const stats = computed(() => documentStatus.value?.stats)
const stats = computed(() => documentStatus.value?.stats ?? null)
const reminderStats = computed(() => documentStatus.value?.reminderStats)
const smtpEnabled = computed(() => configStore.smtpEnabled)
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)
@@ -294,9 +282,10 @@ function cancelRemoveSigner() {
signerToRemove.value = ''
}
function confirmSendReminders() {
function handleReminderSend(mode: 'all' | 'selected') {
sendMode.value = mode
remindersMessage.value =
sendMode.value === 'all'
mode === 'all'
? t('admin.documentDetail.confirmSendReminders', { count: reminderStats.value?.pendingCount || 0 })
: t('admin.documentDetail.confirmSendRemindersSelected', { count: selectedEmails.value.length })
showSendRemindersModal.value = true
@@ -369,15 +358,6 @@ function formatDate(dateString: string | undefined): string {
})
}
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
@@ -699,171 +679,27 @@ onMounted(() => {
</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>
<SignersSection
:expected-signers="expectedSigners"
:unexpected-signatures="unexpectedSignatures"
:stats="stats"
:show-import-c-s-v="true"
:selected-emails="selectedEmails"
@add-signer="showAddSignersModal = true"
@remove-signer="confirmRemoveSigner"
@import-csv="openImportCSVModal"
@selection-change="(emails) => selectedEmails = emails"
/>
<!-- 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>
<RemindersSection
v-if="reminderStats && stats && stats.expectedCount > 0 && (smtpEnabled || reminderStats.totalSent > 0)"
:reminder-stats="reminderStats"
:smtp-enabled="smtpEnabled"
:selected-emails-count="selectedEmails.length"
:sending="sendingReminders"
@send="handleReminderSend"
/>
<!-- Danger Zone -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-red-200 dark:border-red-800/50">