feat(contracts): Add legal export package and ESIGN compliance improvements
- Add export_legal endpoint for signed contracts that generates a ZIP with: - Signed contract PDF - Audit certificate PDF with signature details and hash verification - Machine-readable signature_record.json - Integrity verification report - README documentation - Add audit certificate template with: - Contract and signature information - Consent records with exact legal text - Document integrity verification (SHA-256 hash comparison) - ESIGN Act and UETA compliance statement - Update ContractSigning page for ESIGN/UETA compliance: - Consent checkbox text now matches backend-stored legal text - Added proper legal notice with ESIGN Act references - Add signed_at field to ContractListSerializer - Add view/print buttons for signed contracts in Contracts page - Allow viewing signed contracts via public signing URL 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,36 +1,43 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { FileSignature, CheckCircle, XCircle, Loader } from 'lucide-react';
|
||||
import { FileSignature, CheckCircle, XCircle, Loader, Printer, Download } from 'lucide-react';
|
||||
import { usePublicContract, useSignContract } from '../hooks/useContracts';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
const ContractSigning: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
const [signerName, setSignerName] = useState('');
|
||||
const [signerEmail, setSignerEmail] = useState('');
|
||||
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||
const [signatureData, setSignatureData] = useState('');
|
||||
const [electronicConsent, setElectronicConsent] = useState(false);
|
||||
|
||||
const { data: contractData, isLoading, error } = usePublicContract(token || '');
|
||||
const { data: contractData, isLoading, error, refetch } = usePublicContract(token || '');
|
||||
const signMutation = useSignContract();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!token || !signatureData || !agreedToTerms) return;
|
||||
if (!token || !signerName.trim() || !agreedToTerms || !electronicConsent) return;
|
||||
|
||||
try {
|
||||
await signMutation.mutateAsync({
|
||||
token,
|
||||
signature_data: signatureData,
|
||||
signer_name: signerName,
|
||||
signer_email: signerEmail,
|
||||
signer_name: signerName.trim(),
|
||||
consent_checkbox_checked: agreedToTerms,
|
||||
electronic_consent_given: electronicConsent,
|
||||
});
|
||||
// Refetch to get updated contract with signature data
|
||||
await refetch();
|
||||
} catch (error) {
|
||||
console.error('Failed to sign contract:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
window.print();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
@@ -42,7 +49,8 @@ const ContractSigning: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !contractData) {
|
||||
if (error || !contractData || !contractData.contract) {
|
||||
console.error('Contract loading error:', { error, contractData });
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md text-center">
|
||||
@@ -58,27 +66,6 @@ const ContractSigning: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (contractData.contract.status === 'SIGNED') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md text-center">
|
||||
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
{t('contracts.signing.alreadySigned')}
|
||||
</h2>
|
||||
{contractData.signature && (
|
||||
<p className="text-gray-600">
|
||||
{t('contracts.signing.signedBy', {
|
||||
name: contractData.signature.signer_name,
|
||||
date: new Date(contractData.signature.signed_at).toLocaleDateString(),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (contractData.is_expired) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
@@ -95,22 +82,155 @@ const ContractSigning: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (signMutation.isSuccess) {
|
||||
// Show signed contract view
|
||||
if (contractData.contract?.status === 'SIGNED' || signMutation.isSuccess) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md text-center">
|
||||
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
{t('contracts.signing.success')}
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
{t('contracts.signing.thankYou')}
|
||||
</p>
|
||||
<div className="min-h-screen bg-gray-50 py-8 print:py-0 print:bg-white">
|
||||
<div className="max-w-4xl mx-auto px-4 print:px-0 print:max-w-none">
|
||||
{/* Print Header - Only visible when printing */}
|
||||
<div className="hidden print:block mb-8 border-b-2 border-gray-300 pb-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{contractData.business.name}</h1>
|
||||
<p className="text-gray-600">{contractData.template.name}</p>
|
||||
</div>
|
||||
<div className="text-right text-sm text-gray-600">
|
||||
<p>Contract ID: {contractData.contract.id}</p>
|
||||
<p>Status: SIGNED</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Success Banner - Hidden when printing */}
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-6 mb-6 print:hidden">
|
||||
<div className="flex items-center gap-4">
|
||||
<CheckCircle className="w-12 h-12 text-green-500 flex-shrink-0" />
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-green-800">
|
||||
Contract Successfully Signed
|
||||
</h2>
|
||||
<p className="text-green-700">
|
||||
This contract has been signed and is now legally binding.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons - Hidden when printing */}
|
||||
<div className="flex gap-4 mb-6 print:hidden">
|
||||
<button
|
||||
onClick={handlePrint}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Printer className="w-5 h-5" />
|
||||
Print Contract
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Contract Header */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-8 mb-6 print:shadow-none print:p-0 print:mb-4">
|
||||
<div className="flex items-center gap-3 mb-4 print:hidden">
|
||||
{contractData.business.logo_url ? (
|
||||
<img
|
||||
src={contractData.business.logo_url}
|
||||
alt={contractData.business.name}
|
||||
className="h-12 object-contain"
|
||||
/>
|
||||
) : (
|
||||
<FileSignature className="w-12 h-12 text-blue-600" />
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{contractData.business.name}
|
||||
</h1>
|
||||
<p className="text-gray-600">{contractData.template.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
{contractData.customer && (
|
||||
<p className="text-gray-600">
|
||||
Contract for: <strong>{contractData.customer.name}</strong>
|
||||
{contractData.customer.email && ` (${contractData.customer.email})`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contract Content */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-8 mb-6 print:shadow-none print:p-0 print:mb-4">
|
||||
<div
|
||||
className="prose max-w-none print:prose-sm"
|
||||
dangerouslySetInnerHTML={{ __html: contractData.contract.content }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Signature Details */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-8 print:shadow-none print:p-0 print:border-t-2 print:border-gray-300 print:pt-4">
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-4 print:text-base">
|
||||
Electronic Signature Record
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 print:gap-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Signer Name</p>
|
||||
<p className="text-lg font-medium text-gray-900">
|
||||
{contractData.signature?.signer_name || signerName || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Signer Email</p>
|
||||
<p className="text-gray-900">
|
||||
{contractData.signature?.signer_email || contractData.customer?.email || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Date Signed</p>
|
||||
<p className="text-gray-900">
|
||||
{contractData.signature?.signed_at
|
||||
? new Date(contractData.signature.signed_at).toLocaleString()
|
||||
: new Date().toLocaleString()
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Electronic Signature</p>
|
||||
<div className="mt-2 p-4 bg-gray-50 border border-gray-200 rounded-lg print:bg-white print:border-gray-400">
|
||||
<p className="text-2xl italic text-gray-800" style={{ fontFamily: 'cursive' }}>
|
||||
{contractData.signature?.signer_name || signerName || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Contract Status</p>
|
||||
<span className="inline-flex items-center gap-1 px-3 py-1 bg-green-100 text-green-800 rounded-full text-sm font-medium print:bg-transparent print:text-green-700 print:px-0">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
Signed
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legal Notice */}
|
||||
<div className="mt-6 pt-6 border-t border-gray-200 print:mt-4 print:pt-4">
|
||||
<p className="text-xs text-gray-500 print:text-gray-600">
|
||||
This document was signed electronically in compliance with the Electronic Signatures in Global and National Commerce Act (ESIGN Act, 15 U.S.C. section 7001 et seq.) and the Uniform Electronic Transactions Act (UETA). The signer expressly agreed to the terms and conditions, consented to conduct business electronically, and acknowledged that their electronic signature is the legal equivalent of a handwritten signature. A complete audit trail including timestamp, IP address, and document hash is maintained.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Print Footer - Only visible when printing */}
|
||||
<div className="hidden print:block mt-8 pt-4 border-t border-gray-300 text-xs text-gray-500">
|
||||
<p>Printed on: {new Date().toLocaleString()}</p>
|
||||
<p>This is a copy of an electronically signed document.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show signing form
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-12">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
@@ -143,7 +263,7 @@ const ContractSigning: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Contract Content */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-8 mb-6">
|
||||
<div className="bg-white rounded-lg shadow-lg p-8 mb-6 overflow-auto">
|
||||
<div
|
||||
className="prose max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: contractData.contract.content }}
|
||||
@@ -161,88 +281,85 @@ const ContractSigning: React.FC = () => {
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-6">
|
||||
{/* Electronic Signature - Type Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t('contracts.signing.signerName')}
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Type your full legal name to sign
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={signerName}
|
||||
onChange={(e) => setSignerName(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t('contracts.signing.signerEmail')}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={signerEmail}
|
||||
onChange={(e) => setSignerEmail(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t('contracts.signing.signatureLabel')}
|
||||
</label>
|
||||
<div className="border border-gray-300 rounded-lg p-4 bg-gray-50">
|
||||
<textarea
|
||||
placeholder={t('contracts.signing.signaturePlaceholder')}
|
||||
value={signatureData}
|
||||
onChange={(e) => setSignatureData(e.target.value)}
|
||||
className="w-full h-32 p-2 border border-gray-200 rounded resize-none"
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={signerName}
|
||||
onChange={(e) => setSignerName(e.target.value)}
|
||||
placeholder="Enter your full name"
|
||||
className="w-full px-4 py-3 border-2 border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-lg"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSignatureData('')}
|
||||
className="mt-2 text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
{t('contracts.signing.clearSignature')}
|
||||
</button>
|
||||
</div>
|
||||
{signerName && (
|
||||
<div className="mt-4 p-4 bg-gray-50 border border-gray-200 rounded-lg">
|
||||
<p className="text-xs text-gray-500 mb-2">Signature Preview:</p>
|
||||
<p className="text-2xl italic text-gray-800" style={{ fontFamily: 'cursive' }}>
|
||||
{signerName}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="agree"
|
||||
checked={agreedToTerms}
|
||||
onChange={(e) => setAgreedToTerms(e.target.checked)}
|
||||
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
<label htmlFor="agree" className="text-sm text-gray-700">
|
||||
{t('contracts.signing.agreeToTerms')}
|
||||
</label>
|
||||
{/* Consent Checkboxes - Text must match backend storage for legal compliance */}
|
||||
<div className="space-y-4 p-4 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="agree"
|
||||
checked={agreedToTerms}
|
||||
onChange={(e) => setAgreedToTerms(e.target.checked)}
|
||||
className="w-5 h-5 mt-0.5 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
<label htmlFor="agree" className="text-sm text-gray-700">
|
||||
I have read and agree to the terms and conditions outlined in this document. By checking this box, I understand that this constitutes a legal electronic signature under the ESIGN Act (15 U.S.C. section 7001 et seq.) and UETA.
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="electronic-consent"
|
||||
checked={electronicConsent}
|
||||
onChange={(e) => setElectronicConsent(e.target.checked)}
|
||||
className="w-5 h-5 mt-0.5 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
<label htmlFor="electronic-consent" className="text-sm text-gray-700">
|
||||
<span className="font-medium">I consent to conduct business electronically.</span> I understand that: (1) I am agreeing to use electronic records and signatures in place of paper; (2) I have the right to receive documents in paper form upon request; (3) I can withdraw this consent at any time; (4) I need internet access to access these documents; (5) I can request a paper copy at any time.
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={signMutation.isPending || !agreedToTerms}
|
||||
className="w-full px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
disabled={signMutation.isPending || !agreedToTerms || !electronicConsent || !signerName.trim()}
|
||||
className="w-full px-6 py-4 bg-blue-600 text-white text-lg font-semibold rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{signMutation.isPending ? (
|
||||
<>
|
||||
<Loader className="w-5 h-5 animate-spin" />
|
||||
{t('contracts.signing.submitting')}
|
||||
Signing...
|
||||
</>
|
||||
) : (
|
||||
t('contracts.signing.submitSignature')
|
||||
<>
|
||||
<FileSignature className="w-5 h-5" />
|
||||
Sign Contract
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{signMutation.isError && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-sm text-red-600">
|
||||
{t('contracts.signing.error')}
|
||||
Failed to sign the contract. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
FileSignature, Plus, Search, Send, Eye, X, Loader2,
|
||||
Clock, CheckCircle, XCircle, AlertCircle, Copy, RefreshCw, Ban,
|
||||
ExternalLink, ChevronDown, ChevronRight, User, Pencil, Trash2,
|
||||
ExternalLink, ChevronDown, ChevronRight, User, Pencil, Trash2, Check, Printer,
|
||||
Download, FileArchive,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useContracts,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
useCreateContractTemplate,
|
||||
useUpdateContractTemplate,
|
||||
useDeleteContractTemplate,
|
||||
useExportLegalPackage,
|
||||
} from '../hooks/useContracts';
|
||||
import { useCustomers } from '../hooks/useCustomers';
|
||||
import apiClient from '../api/client';
|
||||
@@ -81,6 +83,7 @@ const Contracts: React.FC = () => {
|
||||
const sendContract = useSendContract();
|
||||
const voidContract = useVoidContract();
|
||||
const resendContract = useResendContract();
|
||||
const exportLegalPackage = useExportLegalPackage();
|
||||
|
||||
// Template mutations
|
||||
const createTemplate = useCreateContractTemplate();
|
||||
@@ -158,7 +161,9 @@ const Contracts: React.FC = () => {
|
||||
// Handlers
|
||||
const handleSelectCustomer = (customer: Customer) => {
|
||||
setSelectedCustomerId(customer.userId || customer.id);
|
||||
setSelectedCustomerName(`${customer.name} (${customer.email})`);
|
||||
const displayName = customer.name || customer.email || `Customer #${customer.id}`;
|
||||
const displayEmail = customer.email || 'No email';
|
||||
setSelectedCustomerName(`${displayName} (${displayEmail})`);
|
||||
setCustomerSearchTerm('');
|
||||
setIsCustomerDropdownOpen(false);
|
||||
};
|
||||
@@ -174,15 +179,12 @@ const Contracts: React.FC = () => {
|
||||
if (!selectedTemplateId || !selectedCustomerId) return;
|
||||
|
||||
try {
|
||||
const result = await createContract.mutateAsync({
|
||||
await createContract.mutateAsync({
|
||||
template: selectedTemplateId,
|
||||
customer: selectedCustomerId,
|
||||
customer_id: selectedCustomerId,
|
||||
send_email: sendEmailOnCreate,
|
||||
});
|
||||
|
||||
if (sendEmailOnCreate && result.id) {
|
||||
await sendContract.mutateAsync(String(result.id));
|
||||
}
|
||||
|
||||
setIsCreateContractModalOpen(false);
|
||||
setSelectedTemplateId('');
|
||||
setSelectedCustomerId('');
|
||||
@@ -225,11 +227,41 @@ const Contracts: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const copySigningLink = (contract: Contract) => {
|
||||
if (contract.public_token) {
|
||||
const link = `${window.location.origin}/sign/${contract.public_token}`;
|
||||
navigator.clipboard.writeText(link);
|
||||
const [copiedContractId, setCopiedContractId] = useState<string | null>(null);
|
||||
|
||||
const copySigningLink = async (contract: Contract) => {
|
||||
if (!contract.public_token) {
|
||||
console.warn('No public_token found on contract:', contract);
|
||||
return;
|
||||
}
|
||||
|
||||
const link = `${window.location.origin}/sign/${contract.public_token}`;
|
||||
|
||||
// Helper to copy using fallback method
|
||||
const fallbackCopy = () => {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = link;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-9999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
};
|
||||
|
||||
// Use clipboard API if available (requires HTTPS), otherwise fallback
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(link);
|
||||
} catch {
|
||||
fallbackCopy();
|
||||
}
|
||||
} else {
|
||||
fallbackCopy();
|
||||
}
|
||||
|
||||
setCopiedContractId(contract.id);
|
||||
setTimeout(() => setCopiedContractId(null), 2000);
|
||||
};
|
||||
|
||||
// Template handlers
|
||||
@@ -364,11 +396,11 @@ const Contracts: React.FC = () => {
|
||||
|
||||
{/* Templates Section */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<button
|
||||
onClick={() => setTemplatesExpanded(!templatesExpanded)}
|
||||
className="w-full flex items-center justify-between p-4 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-full flex items-center justify-between p-4">
|
||||
<button
|
||||
onClick={() => setTemplatesExpanded(!templatesExpanded)}
|
||||
className="flex items-center gap-3 text-left hover:opacity-80 transition-opacity"
|
||||
>
|
||||
{templatesExpanded ? <ChevronDown size={20} /> : <ChevronRight size={20} />}
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{t('contracts.templates')}
|
||||
@@ -376,18 +408,15 @@ const Contracts: React.FC = () => {
|
||||
<span className="px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-sm">
|
||||
{allTemplates?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openCreateTemplateModal();
|
||||
}}
|
||||
onClick={() => openCreateTemplateModal()}
|
||||
className="px-3 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Plus size={16} />
|
||||
{t('contracts.newTemplate')}
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{templatesExpanded && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-4 space-y-4">
|
||||
@@ -497,11 +526,11 @@ const Contracts: React.FC = () => {
|
||||
|
||||
{/* Contracts Section */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<button
|
||||
onClick={() => setContractsExpanded(!contractsExpanded)}
|
||||
className="w-full flex items-center justify-between p-4 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-full flex items-center justify-between p-4">
|
||||
<button
|
||||
onClick={() => setContractsExpanded(!contractsExpanded)}
|
||||
className="flex items-center gap-3 text-left hover:opacity-80 transition-opacity"
|
||||
>
|
||||
{contractsExpanded ? <ChevronDown size={20} /> : <ChevronRight size={20} />}
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{t('contracts.sentContracts')}
|
||||
@@ -509,19 +538,16 @@ const Contracts: React.FC = () => {
|
||||
<span className="px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-sm">
|
||||
{contracts?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsCreateContractModalOpen(true);
|
||||
}}
|
||||
onClick={() => setIsCreateContractModalOpen(true)}
|
||||
disabled={!activeTemplates || activeTemplates.length === 0}
|
||||
className="px-3 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus size={16} />
|
||||
{t('contracts.actions.send')}
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{contractsExpanded && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-4 space-y-4">
|
||||
@@ -600,8 +626,8 @@ const Contracts: React.FC = () => {
|
||||
</button>
|
||||
{contract.status === 'PENDING' && (
|
||||
<>
|
||||
<button onClick={() => copySigningLink(contract)} className="p-1.5 text-gray-500 hover:text-blue-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors" title={t('contracts.actions.copyLink')}>
|
||||
<Copy size={16} />
|
||||
<button onClick={() => copySigningLink(contract)} className={`p-1.5 rounded transition-colors ${copiedContractId === contract.id ? 'text-green-600 bg-green-100 dark:bg-green-900/30' : 'text-gray-500 hover:text-blue-600 hover:bg-gray-100 dark:hover:bg-gray-700'}`} title={copiedContractId === contract.id ? t('contracts.actions.copied') : t('contracts.actions.copyLink')}>
|
||||
{copiedContractId === contract.id ? <Check size={16} /> : <Copy size={16} />}
|
||||
</button>
|
||||
{!contract.sent_at ? (
|
||||
<button onClick={() => handleSendContract(contract.id)} disabled={sendContract.isPending} className="p-1.5 text-gray-500 hover:text-green-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors" title={t('contracts.actions.sendEmail')}>
|
||||
@@ -733,6 +759,21 @@ const Contracts: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Signed Contract Banner */}
|
||||
{viewingContract.status === 'SIGNED' && (
|
||||
<div className="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="w-8 h-8 text-green-500 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold text-green-800 dark:text-green-300">{t('contracts.contractDetails.signedTitle')}</p>
|
||||
<p className="text-sm text-green-700 dark:text-green-400">
|
||||
{t('contracts.signedAt')}: {formatDate(viewingContract.signed_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{t('contracts.contractDetails.customer')}</p>
|
||||
@@ -758,13 +799,57 @@ const Contracts: React.FC = () => {
|
||||
<div className="prose dark:prose-invert max-w-none p-4 border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-900 max-h-64 overflow-y-auto" dangerouslySetInnerHTML={{ __html: viewingContract.content }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons for Signed Contracts */}
|
||||
{viewingContract.status === 'SIGNED' && viewingContract.public_token && (
|
||||
<div className="flex flex-wrap gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<a
|
||||
href={`/sign/${viewingContract.public_token}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Eye size={18} />
|
||||
{t('contracts.contractDetails.viewFullContract')}
|
||||
</a>
|
||||
<button
|
||||
onClick={() => {
|
||||
const url = `/sign/${viewingContract.public_token}`;
|
||||
const printWindow = window.open(url, '_blank');
|
||||
if (printWindow) {
|
||||
printWindow.onload = () => {
|
||||
setTimeout(() => printWindow.print(), 500);
|
||||
};
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<Printer size={18} />
|
||||
{t('contracts.contractDetails.printContract')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => exportLegalPackage.mutate(viewingContract.id)}
|
||||
disabled={exportLegalPackage.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{exportLegalPackage.isPending ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<FileArchive size={18} />
|
||||
)}
|
||||
{t('contracts.contractDetails.exportLegal')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Signing Link for Pending Contracts */}
|
||||
{viewingContract.public_token && viewingContract.status === 'PENDING' && (
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<p className="text-sm font-medium text-blue-700 dark:text-blue-300 mb-2">{t('contracts.contractDetails.signingLink')}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="text" readOnly value={`${window.location.origin}/sign/${viewingContract.public_token}`} className="flex-1 px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-blue-200 dark:border-blue-700 rounded" />
|
||||
<button onClick={() => copySigningLink(viewingContract)} className="px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||
<Copy size={16} />
|
||||
<button onClick={() => copySigningLink(viewingContract)} className={`px-3 py-2 rounded transition-colors ${copiedContractId === viewingContract.id ? 'bg-green-600 text-white' : 'bg-blue-600 text-white hover:bg-blue-700'}`}>
|
||||
{copiedContractId === viewingContract.id ? <Check size={16} /> : <Copy size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user