feat(05-03): create TextFillForm and PreparePanel client components
- TextFillForm: key-value pair builder (up to 10 rows, individually removable) - PreparePanel: client selector + text fill form + Prepare and Send button - PreparePanel calls POST /api/documents/[id]/prepare and calls router.refresh() on success - PreparePanel shows read-only message for non-Draft documents - Rule 1 fix: PdfViewer page.scale -> scale (PageCallback has no .scale property; use state var)
This commit is contained in:
@@ -81,7 +81,7 @@ export function PdfViewer({ docId }: { docId: string }) {
|
|||||||
originalHeight: Math.max(page.view[1], page.view[3]),
|
originalHeight: Math.max(page.view[1], page.view[3]),
|
||||||
width: page.width,
|
width: page.width,
|
||||||
height: page.height,
|
height: page.height,
|
||||||
scale: page.scale,
|
scale, // use the current scale state value (PageCallback has no .scale property)
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { TextFillForm } from './TextFillForm';
|
||||||
|
|
||||||
|
interface Client { id: string; name: string; email: string; }
|
||||||
|
|
||||||
|
interface PreparePanelProps {
|
||||||
|
docId: string;
|
||||||
|
clients: Client[];
|
||||||
|
currentClientId: string;
|
||||||
|
currentStatus: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PreparePanel({ docId, clients, currentClientId, currentStatus }: PreparePanelProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [assignedClientId, setAssignedClientId] = useState(currentClientId);
|
||||||
|
const [textFillData, setTextFillData] = useState<Record<string, string>>({});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||||
|
|
||||||
|
// Don't show the panel if already sent/signed
|
||||||
|
const canPrepare = currentStatus === 'Draft';
|
||||||
|
|
||||||
|
async function handlePrepare() {
|
||||||
|
setLoading(true);
|
||||||
|
setResult(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/documents/${docId}/prepare`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ textFillData, assignedClientId }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: 'Unknown error' }));
|
||||||
|
setResult({ ok: false, message: err.error ?? 'Prepare failed' });
|
||||||
|
} else {
|
||||||
|
setResult({ ok: true, message: 'Document prepared successfully. Status updated to Sent.' });
|
||||||
|
router.refresh(); // Update the page to reflect new status
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setResult({ ok: false, message: String(e) });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canPrepare) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-gray-200 p-4 bg-gray-50 text-sm text-gray-500">
|
||||||
|
Document status is <strong>{currentStatus}</strong> — preparation is only available for Draft documents.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-gray-200 p-4 space-y-4">
|
||||||
|
<h2 className="font-semibold text-gray-900">Prepare Document</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Assign to client</label>
|
||||||
|
<select
|
||||||
|
value={assignedClientId}
|
||||||
|
onChange={e => setAssignedClientId(e.target.value)}
|
||||||
|
className="w-full border rounded px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">— Select client —</option>
|
||||||
|
{clients.map(c => (
|
||||||
|
<option key={c.id} value={c.id}>{c.name} ({c.email})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">Text fill fields</label>
|
||||||
|
<TextFillForm onChange={setTextFillData} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handlePrepare}
|
||||||
|
disabled={loading || !assignedClientId}
|
||||||
|
className="w-full py-2 px-4 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{loading ? 'Preparing...' : 'Prepare and Send'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<p className={`text-sm ${result.ok ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
|
{result.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface TextRow { label: string; value: string; }
|
||||||
|
|
||||||
|
interface TextFillFormProps {
|
||||||
|
onChange: (data: Record<string, string>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextFillForm({ onChange }: TextFillFormProps) {
|
||||||
|
const [rows, setRows] = useState<TextRow[]>([{ label: '', value: '' }]);
|
||||||
|
|
||||||
|
function updateRow(index: number, field: 'label' | 'value', val: string) {
|
||||||
|
const next = rows.map((r, i) => i === index ? { ...r, [field]: val } : r);
|
||||||
|
setRows(next);
|
||||||
|
onChange(Object.fromEntries(next.filter(r => r.label).map(r => [r.label, r.value])));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRow() {
|
||||||
|
if (rows.length >= 10) return;
|
||||||
|
setRows([...rows, { label: '', value: '' }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRow(index: number) {
|
||||||
|
const next = rows.filter((_, i) => i !== index);
|
||||||
|
setRows(next.length ? next : [{ label: '', value: '' }]);
|
||||||
|
onChange(Object.fromEntries(next.filter(r => r.label).map(r => [r.label, r.value])));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Field label = AcroForm field name in the PDF (e.g. "PropertyAddress"). Leave blank to skip.
|
||||||
|
</p>
|
||||||
|
{rows.map((row, i) => (
|
||||||
|
<div key={i} className="flex gap-2 items-center">
|
||||||
|
<input
|
||||||
|
placeholder="Field label"
|
||||||
|
value={row.label}
|
||||||
|
onChange={e => updateRow(i, 'label', e.target.value)}
|
||||||
|
className="flex-1 border rounded px-2 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="Value"
|
||||||
|
value={row.value}
|
||||||
|
onChange={e => updateRow(i, 'value', e.target.value)}
|
||||||
|
className="flex-1 border rounded px-2 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => removeRow(i)}
|
||||||
|
className="text-red-400 hover:text-red-600 text-sm px-1"
|
||||||
|
aria-label="Remove row"
|
||||||
|
type="button"
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{rows.length < 10 && (
|
||||||
|
<button
|
||||||
|
onClick={addRow}
|
||||||
|
className="text-blue-600 hover:text-blue-800 text-sm"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
+ Add field
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user