fix(05-04): simplify recipients to single pre-filled textarea, remove client dropdown
This commit is contained in:
@@ -3,102 +3,51 @@ import { useState } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { TextFillForm } from './TextFillForm';
|
import { TextFillForm } from './TextFillForm';
|
||||||
|
|
||||||
interface Client { id: string; name: string; email: string; }
|
|
||||||
|
|
||||||
interface PreparePanelProps {
|
interface PreparePanelProps {
|
||||||
docId: string;
|
docId: string;
|
||||||
clients: Client[];
|
defaultEmail: string;
|
||||||
/** The client already explicitly assigned to this document (null if not yet assigned). */
|
clientName: string;
|
||||||
assignedClientId: string | null;
|
|
||||||
/** The client the document belongs to — used as the default selection when no explicit assignedClientId. */
|
|
||||||
defaultClientId: string;
|
|
||||||
currentStatus: string;
|
currentStatus: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a comma/newline-separated string of email addresses into a trimmed, deduplicated array.
|
|
||||||
* Empty tokens are filtered out.
|
|
||||||
*/
|
|
||||||
function parseEmails(raw: string): string[] {
|
function parseEmails(raw: string): string[] {
|
||||||
return raw
|
return raw.split(/[\n,]+/).map((e) => e.trim()).filter(Boolean);
|
||||||
.split(/[\n,]+/)
|
|
||||||
.map((e) => e.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Very light email format check — catches obvious mistakes without a full RFC 5322 parse. */
|
|
||||||
function isValidEmail(email: string): boolean {
|
function isValidEmail(email: string): boolean {
|
||||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PreparePanel({
|
export function PreparePanel({ docId, defaultEmail, clientName, currentStatus }: PreparePanelProps) {
|
||||||
docId,
|
|
||||||
clients,
|
|
||||||
assignedClientId,
|
|
||||||
defaultClientId,
|
|
||||||
currentStatus,
|
|
||||||
}: PreparePanelProps) {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [recipients, setRecipients] = useState(defaultEmail);
|
||||||
// selectedClientId: id from the dropdown (empty string = "pick manually by email")
|
|
||||||
const [selectedClientId, setSelectedClientId] = useState<string>(
|
|
||||||
assignedClientId ?? defaultClientId,
|
|
||||||
);
|
|
||||||
|
|
||||||
// primaryEmail: editable email for the assigned client (pre-filled, can be overridden)
|
|
||||||
const assignedClient = clients.find(c => c.id === assignedClientId);
|
|
||||||
const [primaryEmail, setPrimaryEmail] = useState(assignedClient?.email ?? '');
|
|
||||||
|
|
||||||
// emailInput: raw text for manual email entry (used when selectedClientId is '' or
|
|
||||||
// as additional CC addresses)
|
|
||||||
const [emailInput, setEmailInput] = useState('');
|
|
||||||
|
|
||||||
const [textFillData, setTextFillData] = useState<Record<string, string>>({});
|
const [textFillData, setTextFillData] = useState<Record<string, string>>({});
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null);
|
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||||
|
|
||||||
// Don't show the panel if already sent/signed
|
if (currentStatus !== 'Draft') {
|
||||||
const canPrepare = currentStatus === 'Draft';
|
return (
|
||||||
|
<div className="rounded-lg border border-gray-200 p-4 bg-gray-50 text-sm text-gray-500">
|
||||||
// The email addresses that will be sent with the prepare request.
|
Document status is <strong>{currentStatus}</strong> — preparation is only available for Draft documents.
|
||||||
// When an assigned client exists, use the (editable) primaryEmail.
|
</div>
|
||||||
// Otherwise use the dropdown-selected client's email.
|
);
|
||||||
// Any manually-entered addresses are appended.
|
|
||||||
function buildEmailAddresses(): string[] {
|
|
||||||
const addresses: string[] = [];
|
|
||||||
|
|
||||||
if (assignedClientId && primaryEmail.trim()) {
|
|
||||||
addresses.push(primaryEmail.trim());
|
|
||||||
} else if (selectedClientId) {
|
|
||||||
const client = clients.find((c) => c.id === selectedClientId);
|
|
||||||
if (client?.email) addresses.push(client.email);
|
|
||||||
}
|
|
||||||
|
|
||||||
const extras = parseEmails(emailInput);
|
|
||||||
for (const e of extras) {
|
|
||||||
if (!addresses.includes(e)) addresses.push(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
return addresses;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePrepare() {
|
async function handlePrepare() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
|
|
||||||
const emailAddresses = buildEmailAddresses();
|
const emailAddresses = parseEmails(recipients);
|
||||||
|
|
||||||
// Require at least one email address
|
|
||||||
if (emailAddresses.length === 0) {
|
if (emailAddresses.length === 0) {
|
||||||
setResult({ ok: false, message: 'Please select a client or enter at least one email address.' });
|
setResult({ ok: false, message: 'Enter at least one recipient email.' });
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate all addresses
|
|
||||||
const invalid = emailAddresses.filter((e) => !isValidEmail(e));
|
const invalid = emailAddresses.filter((e) => !isValidEmail(e));
|
||||||
if (invalid.length > 0) {
|
if (invalid.length > 0) {
|
||||||
setResult({ ok: false, message: `Invalid email address(es): ${invalid.join(', ')}` });
|
setResult({ ok: false, message: `Invalid email(s): ${invalid.join(', ')}` });
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -107,18 +56,14 @@ export function PreparePanel({
|
|||||||
const res = await fetch(`/api/documents/${docId}/prepare`, {
|
const res = await fetch(`/api/documents/${docId}/prepare`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ textFillData, emailAddresses }),
|
||||||
textFillData,
|
|
||||||
assignedClientId: selectedClientId || null,
|
|
||||||
emailAddresses,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ error: 'Unknown error' }));
|
const err = await res.json().catch(() => ({ error: 'Unknown error' }));
|
||||||
setResult({ ok: false, message: err.error ?? 'Prepare failed' });
|
setResult({ ok: false, message: err.error ?? 'Prepare failed' });
|
||||||
} else {
|
} else {
|
||||||
setResult({ ok: true, message: 'Document prepared successfully. Status updated to Sent.' });
|
setResult({ ok: true, message: 'Document prepared. Status updated to Sent.' });
|
||||||
router.refresh(); // Update the page to reflect new status
|
router.refresh();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setResult({ ok: false, message: String(e) });
|
setResult({ ok: false, message: String(e) });
|
||||||
@@ -127,71 +72,26 @@ export function PreparePanel({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div className="rounded-lg border border-gray-200 p-4 space-y-4">
|
<div className="rounded-lg border border-gray-200 p-4 space-y-4">
|
||||||
<h2 className="font-semibold text-gray-900">Prepare Document</h2>
|
<h2 className="font-semibold text-gray-900">Prepare Document</h2>
|
||||||
|
|
||||||
{/* Primary recipient */}
|
|
||||||
<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">
|
||||||
Recipient email
|
Recipients
|
||||||
</label>
|
|
||||||
{assignedClientId ? (
|
|
||||||
<>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={primaryEmail}
|
|
||||||
onChange={(e) => setPrimaryEmail(e.target.value)}
|
|
||||||
className="w-full border rounded px-2 py-1.5 text-sm"
|
|
||||||
placeholder="recipient@example.com"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-400 mt-0.5">
|
|
||||||
Assigned client: {clients.find(c => c.id === assignedClientId)?.name ?? assignedClientId}
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<select
|
|
||||||
value={selectedClientId}
|
|
||||||
onChange={(e) => setSelectedClientId(e.target.value)}
|
|
||||||
className="w-full border rounded px-2 py-1.5 text-sm"
|
|
||||||
>
|
|
||||||
<option value="">— Enter email manually below —</option>
|
|
||||||
{clients.map((c) => (
|
|
||||||
<option key={c.id} value={c.id}>{c.name} ({c.email})</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Additional / manual email addresses */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
{assignedClientId || selectedClientId
|
|
||||||
? 'Additional email addresses (optional, comma-separated)'
|
|
||||||
: 'Email address(es) — required if no client selected above'}
|
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={emailInput}
|
value={recipients}
|
||||||
onChange={(e) => setEmailInput(e.target.value)}
|
onChange={(e) => setRecipients(e.target.value)}
|
||||||
placeholder={
|
|
||||||
assignedClientId || selectedClientId
|
|
||||||
? 'e.g. cc@example.com, another@example.com'
|
|
||||||
: 'Enter recipient email, e.g. client@example.com'
|
|
||||||
}
|
|
||||||
rows={2}
|
rows={2}
|
||||||
className="w-full border rounded px-2 py-1.5 text-sm resize-none"
|
className="w-full border rounded px-2 py-1.5 text-sm resize-none"
|
||||||
|
placeholder="email@example.com"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-400 mt-0.5">
|
{clientName && (
|
||||||
Separate multiple addresses with commas or new lines.
|
<p className="text-xs text-gray-400 mt-0.5">
|
||||||
</p>
|
{clientName} · add more addresses separated by commas
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -201,7 +101,7 @@ export function PreparePanel({
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handlePrepare}
|
onClick={handlePrepare}
|
||||||
disabled={loading || (assignedClientId ? !primaryEmail.trim() : (!selectedClientId && parseEmails(emailInput).length === 0))}
|
disabled={loading || parseEmails(recipients).length === 0}
|
||||||
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"
|
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"
|
type="button"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { documents, clients } from '@/lib/db/schema';
|
import { documents } from '@/lib/db/schema';
|
||||||
import { eq, asc } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { PdfViewerWrapper } from './_components/PdfViewerWrapper';
|
import { PdfViewerWrapper } from './_components/PdfViewerWrapper';
|
||||||
import { PreparePanel } from './_components/PreparePanel';
|
import { PreparePanel } from './_components/PreparePanel';
|
||||||
@@ -17,13 +17,10 @@ export default async function DocumentPage({
|
|||||||
|
|
||||||
const { docId } = await params;
|
const { docId } = await params;
|
||||||
|
|
||||||
const [doc, allClients] = await Promise.all([
|
const doc = await db.query.documents.findFirst({
|
||||||
db.query.documents.findFirst({
|
where: eq(documents.id, docId),
|
||||||
where: eq(documents.id, docId),
|
with: { client: true },
|
||||||
with: { client: true },
|
});
|
||||||
}),
|
|
||||||
db.select().from(clients).orderBy(asc(clients.name)),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!doc) redirect('/portal/dashboard');
|
if (!doc) redirect('/portal/dashboard');
|
||||||
|
|
||||||
@@ -51,9 +48,8 @@ export default async function DocumentPage({
|
|||||||
<div className="lg:col-span-1">
|
<div className="lg:col-span-1">
|
||||||
<PreparePanel
|
<PreparePanel
|
||||||
docId={docId}
|
docId={docId}
|
||||||
clients={allClients}
|
defaultEmail={doc.client?.email ?? ''}
|
||||||
assignedClientId={doc.assignedClientId ?? null}
|
clientName={doc.client?.name ?? ''}
|
||||||
defaultClientId={doc.clientId}
|
|
||||||
currentStatus={doc.status}
|
currentStatus={doc.status}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user