mirror of
https://github.com/btouchard/ackify-ce.git
synced 2026-02-08 14:58:36 -06:00
fix: user document allocation
refacto: vue components extract, sign & reminder list, align tests to new components
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
BIN
webapp/cypress/fixtures/pdf-exemple.pdf
Normal file
BIN
webapp/cypress/fixtures/pdf-exemple.pdf
Normal file
Binary file not shown.
13
webapp/cypress/tsconfig.json
Normal file
13
webapp/cypress/tsconfig.json
Normal 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"
|
||||
}
|
||||
136
webapp/src/components/RemindersSection.vue
Normal file
136
webapp/src/components/RemindersSection.vue
Normal 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>
|
||||
296
webapp/src/components/SignersSection.vue
Normal file
296
webapp/src/components/SignersSection.vue
Normal 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>
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user