feat: add signature drawing functionality to contract pages
This commit is contained in:
parent
a88efc3e9f
commit
e32a9f69cd
@ -1,25 +1,26 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import PageLayout from '../../../components/PageLayout'
|
import PageLayout from '../../../components/PageLayout'
|
||||||
import useAuthStore from '../../../store/authStore'
|
import useAuthStore from '../../../store/authStore'
|
||||||
import { useUserStatus } from '../../../hooks/useUserStatus'
|
import { useUserStatus } from '../../../hooks/useUserStatus'
|
||||||
import { API_BASE_URL } from '../../../utils/api'
|
import { API_BASE_URL } from '../../../utils/api'
|
||||||
import { useToast } from '../../../components/toast/toastComponent' // NEW
|
import { useToast } from '../../../components/toast/toastComponent'
|
||||||
|
|
||||||
export default function CompanySignContractPage() {
|
export default function CompanySignContractPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { accessToken } = useAuthStore()
|
const { accessToken } = useAuthStore()
|
||||||
const { refreshStatus } = useUserStatus()
|
const { refreshStatus } = useUserStatus()
|
||||||
const { showToast } = useToast() // NEW
|
const { showToast } = useToast()
|
||||||
|
|
||||||
const [companyName, setCompanyName] = useState('')
|
const [companyName, setCompanyName] = useState('')
|
||||||
const [repName, setRepName] = useState('')
|
const [repName, setRepName] = useState('')
|
||||||
const [repTitle, setRepTitle] = useState('')
|
const [repTitle, setRepTitle] = useState('')
|
||||||
const [location, setLocation] = useState('')
|
const [location, setLocation] = useState('')
|
||||||
const [date, setDate] = useState('')
|
|
||||||
const [note, setNote] = useState('')
|
const [note, setNote] = useState('')
|
||||||
|
const [date, setDate] = useState('')
|
||||||
|
const [signatureDataUrl, setSignatureDataUrl] = useState('')
|
||||||
const [agreeContract, setAgreeContract] = useState(false)
|
const [agreeContract, setAgreeContract] = useState(false)
|
||||||
const [agreeData, setAgreeData] = useState(false)
|
const [agreeData, setAgreeData] = useState(false)
|
||||||
const [confirmSignature, setConfirmSignature] = useState(false)
|
const [confirmSignature, setConfirmSignature] = useState(false)
|
||||||
@ -31,9 +32,91 @@ export default function CompanySignContractPage() {
|
|||||||
const [previewError, setPreviewError] = useState<string | null>(null)
|
const [previewError, setPreviewError] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDate(new Date().toISOString().slice(0,10))
|
setDate(new Date().toISOString().slice(0, 10))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||||
|
const isDrawing = useRef(false)
|
||||||
|
const scaleRef = useRef(1)
|
||||||
|
|
||||||
|
const getPos = (e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return { x: 0, y: 0 }
|
||||||
|
const rect = canvas.getBoundingClientRect()
|
||||||
|
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX
|
||||||
|
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY
|
||||||
|
const x = (clientX - rect.left) * scaleRef.current
|
||||||
|
const y = (clientY - rect.top) * scaleRef.current
|
||||||
|
return { x, y }
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
const resize = () => {
|
||||||
|
const rect = canvas.getBoundingClientRect()
|
||||||
|
const dpr = window.devicePixelRatio || 1
|
||||||
|
canvas.width = rect.width * dpr
|
||||||
|
canvas.height = rect.height * dpr
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (ctx) {
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.lineCap = 'round'
|
||||||
|
ctx.strokeStyle = '#0f172a'
|
||||||
|
}
|
||||||
|
scaleRef.current = dpr
|
||||||
|
}
|
||||||
|
resize()
|
||||||
|
window.addEventListener('resize', resize)
|
||||||
|
return () => window.removeEventListener('resize', resize)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const startDrawing = (e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.lineCap = 'round'
|
||||||
|
ctx.strokeStyle = '#0f172a'
|
||||||
|
const { x, y } = getPos(e)
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(x, y)
|
||||||
|
isDrawing.current = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const draw = (e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!isDrawing.current) return
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
const { x, y } = getPos(e)
|
||||||
|
ctx.lineTo(x, y)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
const endDrawing = () => {
|
||||||
|
if (!isDrawing.current) return
|
||||||
|
isDrawing.current = false
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
const dataUrl = canvas.toDataURL('image/png')
|
||||||
|
setSignatureDataUrl(dataUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearSignature = () => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
setSignatureDataUrl('')
|
||||||
|
}
|
||||||
|
|
||||||
// Load latest contract preview for company user
|
// Load latest contract preview for company user
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadPreview = async () => {
|
const loadPreview = async () => {
|
||||||
@ -41,10 +124,10 @@ export default function CompanySignContractPage() {
|
|||||||
setPreviewLoading(true)
|
setPreviewLoading(true)
|
||||||
setPreviewError(null)
|
setPreviewError(null)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE_URL}/api/contracts/preview/latest`, {
|
const res = await fetch(`${API_BASE_URL}/api/contracts/preview/latest?contract_type=company`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => '')
|
const text = await res.text().catch(() => '')
|
||||||
@ -64,15 +147,16 @@ export default function CompanySignContractPage() {
|
|||||||
}, [accessToken])
|
}, [accessToken])
|
||||||
|
|
||||||
const valid = () => {
|
const valid = () => {
|
||||||
const companyValid = companyName.trim().length >= 3 // Min 3 characters for company name
|
const companyValid = companyName.trim().length >= 3
|
||||||
const repNameValid = repName.trim().length >= 3 // Min 3 characters for representative name
|
const repNameValid = repName.trim().length >= 3
|
||||||
const repTitleValid = repTitle.trim().length >= 2 // Min 2 characters for title
|
const repTitleValid = repTitle.trim().length >= 2
|
||||||
const locationValid = location.trim().length >= 2 // Min 2 characters for location
|
const locationValid = location.trim().length >= 2
|
||||||
const contractChecked = agreeContract
|
const contractChecked = agreeContract
|
||||||
const dataChecked = agreeData
|
const dataChecked = agreeData
|
||||||
const signatureChecked = confirmSignature
|
const signatureChecked = confirmSignature
|
||||||
|
const signatureDrawn = !!signatureDataUrl
|
||||||
|
|
||||||
return companyValid && repNameValid && repTitleValid && locationValid && contractChecked && dataChecked && signatureChecked
|
return companyValid && repNameValid && repTitleValid && locationValid && contractChecked && dataChecked && signatureChecked && signatureDrawn
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
@ -87,6 +171,7 @@ export default function CompanySignContractPage() {
|
|||||||
if (!agreeContract) issues.push('Contract read and understood')
|
if (!agreeContract) issues.push('Contract read and understood')
|
||||||
if (!agreeData) issues.push('Privacy policy accepted')
|
if (!agreeData) issues.push('Privacy policy accepted')
|
||||||
if (!confirmSignature) issues.push('Electronic signature confirmed')
|
if (!confirmSignature) issues.push('Electronic signature confirmed')
|
||||||
|
if (!signatureDataUrl) issues.push('Signature captured on pad')
|
||||||
|
|
||||||
const msg = `Please complete: ${issues.join(', ')}`
|
const msg = `Please complete: ${issues.join(', ')}`
|
||||||
setError(msg)
|
setError(msg)
|
||||||
@ -117,9 +202,9 @@ export default function CompanySignContractPage() {
|
|||||||
companyName: companyName.trim(),
|
companyName: companyName.trim(),
|
||||||
representativeName: repName.trim(),
|
representativeName: repName.trim(),
|
||||||
representativeTitle: repTitle.trim(),
|
representativeTitle: repTitle.trim(),
|
||||||
location: location.trim(),
|
|
||||||
date,
|
date,
|
||||||
note: note.trim() || null,
|
location: location.trim(),
|
||||||
|
note: note.trim(),
|
||||||
contractType: 'company',
|
contractType: 'company',
|
||||||
confirmations: {
|
confirmations: {
|
||||||
agreeContract,
|
agreeContract,
|
||||||
@ -131,12 +216,11 @@ export default function CompanySignContractPage() {
|
|||||||
// Create FormData for the existing backend endpoint
|
// Create FormData for the existing backend endpoint
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('contractData', JSON.stringify(contractData))
|
formData.append('contractData', JSON.stringify(contractData))
|
||||||
// Create a dummy PDF file since the backend expects one (electronic signature)
|
if (signatureDataUrl) {
|
||||||
const dummyPdfContent = '%PDF-1.4\n1 0 obj\n<<\n/Type /Catalog\n/Pages 2 0 R\n>>\nendobj\n2 0 obj\n<<\n/Type /Pages\n/Kids [3 0 R]\n/Count 1\n>>\nendobj\n3 0 obj\n<<\n/Type /Page\n/Parent 2 0 R\n/MediaBox [0 0 612 792]\n/Contents 4 0 R\n>>\nendobj\n4 0 obj\n<<\n/Length 44\n>>\nstream\nBT\n/F1 12 Tf\n100 700 Td\n(Electronic Signature) Tj\nET\nendstream\nendobj\nxref\n0 5\n0000000000 65535 f \n0000000010 00000 n \n0000000079 00000 n \n0000000136 00000 n \n0000000225 00000 n \ntrailer\n<<\n/Size 5\n/Root 1 0 R\n>>\nstartxref\n319\n%%EOF'
|
formData.append('signatureImage', signatureDataUrl)
|
||||||
const dummyFile = new Blob([dummyPdfContent], { type: 'application/pdf' })
|
}
|
||||||
formData.append('contract', dummyFile, 'electronic_signature.pdf')
|
|
||||||
|
|
||||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/upload/contract/company`, {
|
const response = await fetch(`${API_BASE_URL}/api/upload/contract/company`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${accessToken}`
|
'Authorization': `Bearer ${accessToken}`
|
||||||
@ -190,13 +274,10 @@ export default function CompanySignContractPage() {
|
|||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<div className="relative min-h-screen overflow-hidden bg-slate-50">
|
<div className="relative min-h-screen overflow-hidden bg-slate-50">
|
||||||
{/* Animated background (same as dashboard) */}
|
|
||||||
<div className="pointer-events-none absolute inset-0 z-0">
|
<div className="pointer-events-none absolute inset-0 z-0">
|
||||||
{/* Soft gradient blobs */}
|
|
||||||
<div className="absolute -top-32 -left-20 h-80 w-80 rounded-full bg-blue-400/25 blur-3xl animate-pulse" />
|
<div className="absolute -top-32 -left-20 h-80 w-80 rounded-full bg-blue-400/25 blur-3xl animate-pulse" />
|
||||||
<div className="absolute top-1/4 -right-24 h-96 w-96 rounded-full bg-emerald-400/20 blur-3xl animate-pulse" />
|
<div className="absolute top-1/4 -right-24 h-96 w-96 rounded-full bg-emerald-400/20 blur-3xl animate-pulse" />
|
||||||
<div className="absolute bottom-[-6rem] left-1/4 h-96 w-96 rounded-full bg-indigo-400/20 blur-3xl animate-pulse" />
|
<div className="absolute bottom-[-6rem] left-1/4 h-96 w-96 rounded-full bg-indigo-400/20 blur-3xl animate-pulse" />
|
||||||
{/* Subtle radial highlight */}
|
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(59,130,246,0.14),transparent_60%)]" />
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(59,130,246,0.14),transparent_60%)]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -213,7 +294,6 @@ export default function CompanySignContractPage() {
|
|||||||
Please review the contract details and sign on behalf of the company.
|
Please review the contract details and sign on behalf of the company.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Meta + Preview */}
|
|
||||||
<section className="grid gap-8 lg:grid-cols-2 mb-10">
|
<section className="grid gap-8 lg:grid-cols-2 mb-10">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="rounded-lg border border-gray-200 p-5 bg-gray-50">
|
<div className="rounded-lg border border-gray-200 p-5 bg-gray-50">
|
||||||
@ -257,7 +337,7 @@ export default function CompanySignContractPage() {
|
|||||||
setPreviewLoading(true)
|
setPreviewLoading(true)
|
||||||
setPreviewError(null)
|
setPreviewError(null)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE_URL}/api/contracts/preview/latest`, {
|
const res = await fetch(`${API_BASE_URL}/api/contracts/preview/latest?contract_type=company`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
@ -296,14 +376,11 @@ export default function CompanySignContractPage() {
|
|||||||
|
|
||||||
<hr className="my-10 border-gray-200" />
|
<hr className="my-10 border-gray-200" />
|
||||||
|
|
||||||
{/* Company Signature Fields */}
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-semibold text-[#0F2460] mb-5">Company & Representative</h2>
|
<h2 className="text-sm font-semibold text-[#0F2460] mb-5">Company & Representative</h2>
|
||||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<div className="sm:col-span-2 lg:col-span-2">
|
<div className="sm:col-span-2 lg:col-span-2">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">Company Name *</label>
|
||||||
Company Name *
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
value={companyName}
|
value={companyName}
|
||||||
onChange={e => { setCompanyName(e.target.value); setError('') }}
|
onChange={e => { setCompanyName(e.target.value); setError('') }}
|
||||||
@ -313,9 +390,7 @@ export default function CompanySignContractPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">Date *</label>
|
||||||
Date *
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={date}
|
value={date}
|
||||||
@ -325,9 +400,7 @@ export default function CompanySignContractPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text_sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">Location *</label>
|
||||||
Location *
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
value={location}
|
value={location}
|
||||||
onChange={e => { setLocation(e.target.value); setError('') }}
|
onChange={e => { setLocation(e.target.value); setError('') }}
|
||||||
@ -337,9 +410,7 @@ export default function CompanySignContractPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-2 lg:col-span-1">
|
<div className="sm:col-span-2 lg:col-span-1">
|
||||||
<label className="block text_sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">Representative Name *</label>
|
||||||
Representative Name *
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
value={repName}
|
value={repName}
|
||||||
onChange={e => { setRepName(e.target.value); setError('') }}
|
onChange={e => { setRepName(e.target.value); setError('') }}
|
||||||
@ -349,9 +420,7 @@ export default function CompanySignContractPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-2 lg:col-span-2">
|
<div className="sm:col-span-2 lg:col-span-2">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">Representative Position / Title *</label>
|
||||||
Representative Position / Title *
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
value={repTitle}
|
value={repTitle}
|
||||||
onChange={e => { setRepTitle(e.target.value); setError('') }}
|
onChange={e => { setRepTitle(e.target.value); setError('') }}
|
||||||
@ -361,9 +430,7 @@ export default function CompanySignContractPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-2 lg:col-span-3">
|
<div className="sm:col-span-2 lg:col-span-3">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">Note (optional)</label>
|
||||||
Note (optional)
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
value={note}
|
value={note}
|
||||||
onChange={e => setNote(e.target.value)}
|
onChange={e => setNote(e.target.value)}
|
||||||
@ -372,11 +439,40 @@ export default function CompanySignContractPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">Draw Signature *</label>
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
width={800}
|
||||||
|
height={220}
|
||||||
|
className="w-full h-48 rounded-md border border-gray-200 bg-white shadow-inner touch-none"
|
||||||
|
onMouseDown={startDrawing}
|
||||||
|
onMouseMove={draw}
|
||||||
|
onMouseUp={endDrawing}
|
||||||
|
onMouseLeave={endDrawing}
|
||||||
|
onTouchStart={startDrawing}
|
||||||
|
onTouchMove={draw}
|
||||||
|
onTouchEnd={endDrawing}
|
||||||
|
/>
|
||||||
|
<div className="mt-3 flex items-center gap-3 text-xs text-gray-600">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearSignature}
|
||||||
|
className="inline-flex items-center rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
<span className="text-gray-500">Use mouse or touch to sign. A signature is required.</span>
|
||||||
|
{signatureDataUrl && <span className="text-green-600 font-medium">Captured</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<hr className="my-10 border-gray-200" />
|
<hr className="my-10 border-gray-200" />
|
||||||
|
|
||||||
{/* Confirmations */}
|
|
||||||
<section className="space-y-5">
|
<section className="space-y-5">
|
||||||
<h2 className="text-sm font-semibold text-[#0F2460]">Confirmations</h2>
|
<h2 className="text-sm font-semibold text-[#0F2460]">Confirmations</h2>
|
||||||
<label className="flex items-start gap-3 text-sm text-gray-700">
|
<label className="flex items-start gap-3 text-sm text-gray-700">
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import PageLayout from '../../../components/PageLayout'
|
import PageLayout from '../../../components/PageLayout'
|
||||||
import useAuthStore from '../../../store/authStore'
|
import useAuthStore from '../../../store/authStore'
|
||||||
@ -14,10 +14,8 @@ export default function PersonalSignContractPage() {
|
|||||||
const { refreshStatus } = useUserStatus()
|
const { refreshStatus } = useUserStatus()
|
||||||
const { showToast } = useToast()
|
const { showToast } = useToast()
|
||||||
|
|
||||||
const [fullName, setFullName] = useState('')
|
|
||||||
const [location, setLocation] = useState('')
|
|
||||||
const [date, setDate] = useState('')
|
const [date, setDate] = useState('')
|
||||||
const [note, setNote] = useState('')
|
const [signatureDataUrl, setSignatureDataUrl] = useState('')
|
||||||
const [agreeContract, setAgreeContract] = useState(false)
|
const [agreeContract, setAgreeContract] = useState(false)
|
||||||
const [agreeData, setAgreeData] = useState(false)
|
const [agreeData, setAgreeData] = useState(false)
|
||||||
const [confirmSignature, setConfirmSignature] = useState(false)
|
const [confirmSignature, setConfirmSignature] = useState(false)
|
||||||
@ -38,6 +36,89 @@ export default function PersonalSignContractPage() {
|
|||||||
setDate(new Date().toISOString().slice(0, 10))
|
setDate(new Date().toISOString().slice(0, 10))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||||
|
const isDrawing = useRef(false)
|
||||||
|
const scaleRef = useRef(1)
|
||||||
|
|
||||||
|
const getPos = (e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return { x: 0, y: 0 }
|
||||||
|
const rect = canvas.getBoundingClientRect()
|
||||||
|
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX
|
||||||
|
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY
|
||||||
|
const x = (clientX - rect.left) * scaleRef.current
|
||||||
|
const y = (clientY - rect.top) * scaleRef.current
|
||||||
|
return { x, y }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size canvas to devicePixelRatio to avoid offset between cursor and strokes
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
const resize = () => {
|
||||||
|
const rect = canvas.getBoundingClientRect()
|
||||||
|
const dpr = window.devicePixelRatio || 1
|
||||||
|
canvas.width = rect.width * dpr
|
||||||
|
canvas.height = rect.height * dpr
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (ctx) {
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.lineCap = 'round'
|
||||||
|
ctx.strokeStyle = '#0f172a'
|
||||||
|
}
|
||||||
|
scaleRef.current = dpr
|
||||||
|
}
|
||||||
|
resize()
|
||||||
|
window.addEventListener('resize', resize)
|
||||||
|
return () => window.removeEventListener('resize', resize)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const startDrawing = (e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.lineCap = 'round'
|
||||||
|
ctx.strokeStyle = '#0f172a'
|
||||||
|
const { x, y } = getPos(e)
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(x, y)
|
||||||
|
isDrawing.current = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const draw = (e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!isDrawing.current) return
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
const { x, y } = getPos(e)
|
||||||
|
ctx.lineTo(x, y)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
const endDrawing = () => {
|
||||||
|
if (!isDrawing.current) return
|
||||||
|
isDrawing.current = false
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
const dataUrl = canvas.toDataURL('image/png')
|
||||||
|
setSignatureDataUrl(dataUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearSignature = () => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
setSignatureDataUrl('')
|
||||||
|
}
|
||||||
|
|
||||||
const loadPreview = async (contractType: 'contract' | 'gdpr') => {
|
const loadPreview = async (contractType: 'contract' | 'gdpr') => {
|
||||||
if (!accessToken) return
|
if (!accessToken) return
|
||||||
setPreviewState((prev) => ({
|
setPreviewState((prev) => ({
|
||||||
@ -80,13 +161,12 @@ export default function PersonalSignContractPage() {
|
|||||||
}, [accessToken])
|
}, [accessToken])
|
||||||
|
|
||||||
const valid = () => {
|
const valid = () => {
|
||||||
const nameValid = fullName.trim().length >= 3 // Min 3 characters for name
|
|
||||||
const locationValid = location.trim().length >= 2 // Min 2 characters for location
|
|
||||||
const contractChecked = agreeContract
|
const contractChecked = agreeContract
|
||||||
const dataChecked = agreeData
|
const dataChecked = agreeData
|
||||||
const signatureChecked = confirmSignature
|
const signatureChecked = confirmSignature
|
||||||
|
const signatureDrawn = !!signatureDataUrl
|
||||||
|
|
||||||
return nameValid && locationValid && contractChecked && dataChecked && signatureChecked
|
return contractChecked && dataChecked && signatureChecked && signatureDrawn
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
@ -94,11 +174,10 @@ export default function PersonalSignContractPage() {
|
|||||||
if (!valid()) {
|
if (!valid()) {
|
||||||
// Detailed error message to help debug
|
// Detailed error message to help debug
|
||||||
const issues: string[] = []
|
const issues: string[] = []
|
||||||
if (fullName.trim().length < 3) issues.push('Full name (min 3 characters)')
|
|
||||||
if (location.trim().length < 2) issues.push('Location (min 2 characters)')
|
|
||||||
if (!agreeContract) issues.push('Contract read and understood')
|
if (!agreeContract) issues.push('Contract read and understood')
|
||||||
if (!agreeData) issues.push('Privacy policy accepted')
|
if (!agreeData) issues.push('Privacy policy accepted')
|
||||||
if (!confirmSignature) issues.push('Electronic signature confirmed')
|
if (!confirmSignature) issues.push('Electronic signature confirmed')
|
||||||
|
if (!signatureDataUrl) issues.push('Signature captured on pad')
|
||||||
|
|
||||||
const msg = `Please complete: ${issues.join(', ')}`
|
const msg = `Please complete: ${issues.join(', ')}`
|
||||||
setError(msg)
|
setError(msg)
|
||||||
@ -126,21 +205,16 @@ export default function PersonalSignContractPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const contractData = {
|
const contractData = {
|
||||||
fullName: fullName.trim(),
|
|
||||||
location: location.trim(),
|
|
||||||
date,
|
date,
|
||||||
note: note.trim() || null,
|
contractType: 'personal'
|
||||||
contractType: 'personal',
|
|
||||||
confirmations: {
|
|
||||||
agreeContract,
|
|
||||||
agreeData,
|
|
||||||
confirmSignature
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create FormData for the backend endpoint (no dummy PDF needed; server generates from templates)
|
// Create FormData for the backend endpoint (no dummy PDF needed; server generates from templates)
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('contractData', JSON.stringify(contractData))
|
formData.append('contractData', JSON.stringify(contractData))
|
||||||
|
if (signatureDataUrl) {
|
||||||
|
formData.append('signatureImage', signatureDataUrl)
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/upload/contract/personal`, {
|
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/upload/contract/personal`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -288,59 +362,39 @@ export default function PersonalSignContractPage() {
|
|||||||
|
|
||||||
<hr className="my-10 border-gray-200" />
|
<hr className="my-10 border-gray-200" />
|
||||||
|
|
||||||
{/* Signature Fields */}
|
{/* Signature Pad */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-semibold text-[#0F2460] mb-5">Signature Details</h2>
|
<h2 className="text-sm font-semibold text-[#0F2460] mb-5">Signature</h2>
|
||||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
<div className="sm:col-span-2 lg:col-span-2">
|
<div className="">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Full Name (Signature) *
|
Draw Signature *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||||
value={fullName}
|
<canvas
|
||||||
onChange={e => { setFullName(e.target.value); setError('') }}
|
ref={canvasRef}
|
||||||
placeholder="Vor- und Nachname"
|
width={800}
|
||||||
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
height={220}
|
||||||
required
|
className="w-full h-48 rounded-md border border-gray-200 bg-white shadow-inner touch-none"
|
||||||
/>
|
onMouseDown={startDrawing}
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
onMouseMove={draw}
|
||||||
Must match your official ID.
|
onMouseUp={endDrawing}
|
||||||
</p>
|
onMouseLeave={endDrawing}
|
||||||
</div>
|
onTouchStart={startDrawing}
|
||||||
<div>
|
onTouchMove={draw}
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
onTouchEnd={endDrawing}
|
||||||
Date *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={date}
|
|
||||||
onChange={e => setDate(e.target.value)}
|
|
||||||
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Location *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
value={location}
|
|
||||||
onChange={e => { setLocation(e.target.value); setError('') }}
|
|
||||||
placeholder="z.B. Berlin"
|
|
||||||
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="sm:col-span-2 lg:col-span-3">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Note (optional)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
value={note}
|
|
||||||
onChange={e => setNote(e.target.value)}
|
|
||||||
placeholder="Optionale zusätzliche Bemerkung"
|
|
||||||
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
|
||||||
/>
|
/>
|
||||||
|
<div className="mt-3 flex items-center gap-3 text-xs text-gray-600">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearSignature}
|
||||||
|
className="inline-flex items-center rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
<span className="text-gray-500">Use mouse or touch to sign. A signature is required.</span>
|
||||||
|
{signatureDataUrl && <span className="text-green-600 font-medium">Captured</span>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user