Seth commited on
Commit ·
9ed5724
1
Parent(s): 7debd2c
update
Browse files
frontend/src/components/workspace/CompanyDetailsEditor.jsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import React, { useEffect, useState } from 'react';
|
| 2 |
import { Loader2, Sparkles } from 'lucide-react';
|
| 3 |
import { Button } from '@/components/ui/button';
|
| 4 |
import { Input } from '@/components/ui/input';
|
|
@@ -20,6 +20,10 @@ function formFromDetails(cd, fallbackCompany = '') {
|
|
| 20 |
};
|
| 21 |
}
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
function buildPatch(variant, form, dealLinkedContact) {
|
| 24 |
const detail = {
|
| 25 |
company_name_for_emails: form.companyNameForEmails,
|
|
@@ -51,8 +55,8 @@ function buildPatch(variant, form, dealLinkedContact) {
|
|
| 51 |
}
|
| 52 |
|
| 53 |
/**
|
| 54 |
-
* Editable “Company details” card
|
| 55 |
-
* `
|
| 56 |
*/
|
| 57 |
export default function CompanyDetailsEditor({
|
| 58 |
variant,
|
|
@@ -65,12 +69,16 @@ export default function CompanyDetailsEditor({
|
|
| 65 |
onFetch,
|
| 66 |
fetchLoading = false,
|
| 67 |
fetchError = '',
|
|
|
|
| 68 |
}) {
|
| 69 |
const [form, setForm] = useState(() => formFromDetails(companyDetails, fallbackCompanyName));
|
| 70 |
const [saving, setSaving] = useState(false);
|
|
|
|
| 71 |
|
| 72 |
useEffect(() => {
|
| 73 |
-
|
|
|
|
|
|
|
| 74 |
}, [companyDetails, fallbackCompanyName]);
|
| 75 |
|
| 76 |
const dealLimited = variant === 'deal' && !dealLinkedContact;
|
|
@@ -79,6 +87,7 @@ export default function CompanyDetailsEditor({
|
|
| 79 |
const setField = (key, value) => setForm((f) => ({ ...f, [key]: value }));
|
| 80 |
|
| 81 |
const handleSave = async () => {
|
|
|
|
| 82 |
setSaving(true);
|
| 83 |
try {
|
| 84 |
await onSave(buildPatch(variant, form, dealLinkedContact));
|
|
@@ -87,34 +96,58 @@ export default function CompanyDetailsEditor({
|
|
| 87 |
}
|
| 88 |
};
|
| 89 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
const row = (label, key, opts = {}) => (
|
| 91 |
<div className={opts.span === 2 ? 'sm:col-span-2' : ''}>
|
| 92 |
-
<label className="block text-xs font-medium text-slate-500
|
| 93 |
<Input
|
| 94 |
value={form[key]}
|
| 95 |
onChange={(e) => setField(key, e.target.value)}
|
| 96 |
disabled={isInputDisabled(key)}
|
| 97 |
-
className="
|
| 98 |
placeholder={opts.placeholder}
|
| 99 |
/>
|
| 100 |
</div>
|
| 101 |
);
|
| 102 |
|
| 103 |
return (
|
| 104 |
-
<div className={cn('rounded-xl border border-slate-200 bg-slate-50/50 p-4
|
| 105 |
<div className="flex flex-wrap items-start justify-between gap-2">
|
| 106 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
{showFetch && onFetch ? (
|
| 108 |
<Button
|
| 109 |
type="button"
|
| 110 |
variant="outline"
|
| 111 |
size="sm"
|
| 112 |
-
className="gap-1.5
|
| 113 |
onClick={() => onFetch()}
|
| 114 |
disabled={fetchLoading}
|
| 115 |
>
|
| 116 |
{fetchLoading ? (
|
| 117 |
-
<Loader2 className="h-3.5 w-3.5
|
| 118 |
) : (
|
| 119 |
<Sparkles className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
| 120 |
)}
|
|
@@ -136,31 +169,33 @@ export default function CompanyDetailsEditor({
|
|
| 136 |
</p>
|
| 137 |
) : null}
|
| 138 |
|
| 139 |
-
<div className="grid grid-cols-1
|
| 140 |
{row('Company Name', 'companyName')}
|
| 141 |
{row('Company Name for Emails', 'companyNameForEmails')}
|
| 142 |
{row('Industry', 'industry', { span: 2 })}
|
| 143 |
-
{row('Employees', 'employees'
|
| 144 |
-
{row('Annual Revenue', 'annualRevenue'
|
| 145 |
-
{row('Last Raised At', 'lastRaisedAt'
|
| 146 |
-
{row('Website', 'website', {
|
| 147 |
-
{row('City', 'city'
|
| 148 |
-
{row('State', 'state'
|
| 149 |
-
{row('Country', 'country'
|
| 150 |
</div>
|
| 151 |
|
| 152 |
-
|
| 153 |
-
<
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
<
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
|
|
|
|
|
|
| 164 |
</div>
|
| 165 |
);
|
| 166 |
}
|
|
|
|
| 1 |
+
import React, { useEffect, useState, useRef } from 'react';
|
| 2 |
import { Loader2, Sparkles } from 'lucide-react';
|
| 3 |
import { Button } from '@/components/ui/button';
|
| 4 |
import { Input } from '@/components/ui/input';
|
|
|
|
| 20 |
};
|
| 21 |
}
|
| 22 |
|
| 23 |
+
function formsCompanyEqual(a, b) {
|
| 24 |
+
return Object.keys(a).every((k) => (a[k] || '').trim() === (b[k] || '').trim());
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
function buildPatch(variant, form, dealLinkedContact) {
|
| 28 |
const detail = {
|
| 29 |
company_name_for_emails: form.companyNameForEmails,
|
|
|
|
| 55 |
}
|
| 56 |
|
| 57 |
/**
|
| 58 |
+
* Editable “Company details” card for Contacts / Leads / Deals slide-overs.
|
| 59 |
+
* `autoSave`: debounced save after typing pauses (no Save button).
|
| 60 |
*/
|
| 61 |
export default function CompanyDetailsEditor({
|
| 62 |
variant,
|
|
|
|
| 69 |
onFetch,
|
| 70 |
fetchLoading = false,
|
| 71 |
fetchError = '',
|
| 72 |
+
autoSave = false,
|
| 73 |
}) {
|
| 74 |
const [form, setForm] = useState(() => formFromDetails(companyDetails, fallbackCompanyName));
|
| 75 |
const [saving, setSaving] = useState(false);
|
| 76 |
+
const baselineRef = useRef(formFromDetails(companyDetails, fallbackCompanyName));
|
| 77 |
|
| 78 |
useEffect(() => {
|
| 79 |
+
const b = formFromDetails(companyDetails, fallbackCompanyName);
|
| 80 |
+
baselineRef.current = b;
|
| 81 |
+
setForm(b);
|
| 82 |
}, [companyDetails, fallbackCompanyName]);
|
| 83 |
|
| 84 |
const dealLimited = variant === 'deal' && !dealLinkedContact;
|
|
|
|
| 87 |
const setField = (key, value) => setForm((f) => ({ ...f, [key]: value }));
|
| 88 |
|
| 89 |
const handleSave = async () => {
|
| 90 |
+
if (!onSave) return;
|
| 91 |
setSaving(true);
|
| 92 |
try {
|
| 93 |
await onSave(buildPatch(variant, form, dealLinkedContact));
|
|
|
|
| 96 |
}
|
| 97 |
};
|
| 98 |
|
| 99 |
+
useEffect(() => {
|
| 100 |
+
if (!autoSave || !onSave) return;
|
| 101 |
+
const baseline = baselineRef.current;
|
| 102 |
+
if (formsCompanyEqual(form, baseline)) return;
|
| 103 |
+
const t = setTimeout(async () => {
|
| 104 |
+
setSaving(true);
|
| 105 |
+
try {
|
| 106 |
+
await onSave(buildPatch(variant, form, dealLinkedContact));
|
| 107 |
+
baselineRef.current = { ...form };
|
| 108 |
+
} finally {
|
| 109 |
+
setSaving(false);
|
| 110 |
+
}
|
| 111 |
+
}, 700);
|
| 112 |
+
return () => clearTimeout(t);
|
| 113 |
+
}, [form, autoSave, onSave, variant, dealLinkedContact]);
|
| 114 |
+
|
| 115 |
const row = (label, key, opts = {}) => (
|
| 116 |
<div className={opts.span === 2 ? 'sm:col-span-2' : ''}>
|
| 117 |
+
<label className="mb-1 block text-xs font-medium text-slate-500">{label}</label>
|
| 118 |
<Input
|
| 119 |
value={form[key]}
|
| 120 |
onChange={(e) => setField(key, e.target.value)}
|
| 121 |
disabled={isInputDisabled(key)}
|
| 122 |
+
className="bg-white text-sm"
|
| 123 |
placeholder={opts.placeholder}
|
| 124 |
/>
|
| 125 |
</div>
|
| 126 |
);
|
| 127 |
|
| 128 |
return (
|
| 129 |
+
<div className={cn('mb-6 flex flex-col gap-4 rounded-xl border border-slate-200 bg-slate-50/50 p-4', className)}>
|
| 130 |
<div className="flex flex-wrap items-start justify-between gap-2">
|
| 131 |
+
<div className="flex items-center gap-2">
|
| 132 |
+
<h4 className="text-sm font-semibold text-slate-700">Company details</h4>
|
| 133 |
+
{autoSave && saving ? (
|
| 134 |
+
<span className="flex items-center gap-1 text-xs text-slate-500">
|
| 135 |
+
<Loader2 className="h-3 w-3 shrink-0 animate-spin" aria-hidden />
|
| 136 |
+
Saving…
|
| 137 |
+
</span>
|
| 138 |
+
) : null}
|
| 139 |
+
</div>
|
| 140 |
{showFetch && onFetch ? (
|
| 141 |
<Button
|
| 142 |
type="button"
|
| 143 |
variant="outline"
|
| 144 |
size="sm"
|
| 145 |
+
className="shrink-0 gap-1.5 border-violet-200 text-violet-700 hover:bg-violet-50"
|
| 146 |
onClick={() => onFetch()}
|
| 147 |
disabled={fetchLoading}
|
| 148 |
>
|
| 149 |
{fetchLoading ? (
|
| 150 |
+
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" aria-hidden />
|
| 151 |
) : (
|
| 152 |
<Sparkles className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
| 153 |
)}
|
|
|
|
| 169 |
</p>
|
| 170 |
) : null}
|
| 171 |
|
| 172 |
+
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
|
| 173 |
{row('Company Name', 'companyName')}
|
| 174 |
{row('Company Name for Emails', 'companyNameForEmails')}
|
| 175 |
{row('Industry', 'industry', { span: 2 })}
|
| 176 |
+
{row('Employees', 'employees')}
|
| 177 |
+
{row('Annual Revenue', 'annualRevenue')}
|
| 178 |
+
{row('Last Raised At', 'lastRaisedAt')}
|
| 179 |
+
{row('Website', 'website', { placeholder: 'https://' })}
|
| 180 |
+
{row('City', 'city')}
|
| 181 |
+
{row('State', 'state')}
|
| 182 |
+
{row('Country', 'country')}
|
| 183 |
</div>
|
| 184 |
|
| 185 |
+
{!autoSave ? (
|
| 186 |
+
<div className="border-t border-slate-200/80 pt-2">
|
| 187 |
+
<Button type="button" className="w-full" onClick={handleSave} disabled={saving}>
|
| 188 |
+
{saving ? (
|
| 189 |
+
<>
|
| 190 |
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
|
| 191 |
+
Saving…
|
| 192 |
+
</>
|
| 193 |
+
) : (
|
| 194 |
+
'Save'
|
| 195 |
+
)}
|
| 196 |
+
</Button>
|
| 197 |
+
</div>
|
| 198 |
+
) : null}
|
| 199 |
</div>
|
| 200 |
);
|
| 201 |
}
|
frontend/src/components/workspace/ContactIdentityEditor.jsx
CHANGED
|
@@ -1,11 +1,31 @@
|
|
| 1 |
-
import React, { useEffect, useState } from 'react';
|
| 2 |
import { Loader2 } from 'lucide-react';
|
| 3 |
import { Button } from '@/components/ui/button';
|
| 4 |
import { Input } from '@/components/ui/input';
|
| 5 |
import { cn } from '@/lib/utils';
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
/**
|
| 8 |
-
* Editable person fields (first / last name, email, title)
|
|
|
|
|
|
|
| 9 |
*/
|
| 10 |
export default function ContactIdentityEditor({
|
| 11 |
firstName = '',
|
|
@@ -16,17 +36,16 @@ export default function ContactIdentityEditor({
|
|
| 16 |
disabled = false,
|
| 17 |
className,
|
| 18 |
heading = 'Contact details',
|
|
|
|
| 19 |
}) {
|
| 20 |
-
const [form, setForm] = useState(
|
| 21 |
-
firstName,
|
| 22 |
-
lastName,
|
| 23 |
-
email,
|
| 24 |
-
title,
|
| 25 |
-
});
|
| 26 |
const [saving, setSaving] = useState(false);
|
|
|
|
| 27 |
|
| 28 |
useEffect(() => {
|
| 29 |
-
|
|
|
|
|
|
|
| 30 |
}, [firstName, lastName, email, title]);
|
| 31 |
|
| 32 |
const setField = (key, value) => setForm((f) => ({ ...f, [key]: value }));
|
|
@@ -46,12 +65,46 @@ export default function ContactIdentityEditor({
|
|
| 46 |
}
|
| 47 |
};
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
return (
|
| 50 |
<div className={cn('rounded-xl border border-slate-200 bg-white p-4 mb-6', className)}>
|
| 51 |
-
<
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
<div>
|
| 54 |
-
<label className="block text-xs font-medium text-slate-500
|
| 55 |
<Input
|
| 56 |
value={form.firstName}
|
| 57 |
onChange={(e) => setField('firstName', e.target.value)}
|
|
@@ -60,7 +113,7 @@ export default function ContactIdentityEditor({
|
|
| 60 |
/>
|
| 61 |
</div>
|
| 62 |
<div>
|
| 63 |
-
<label className="block text-xs font-medium text-slate-500
|
| 64 |
<Input
|
| 65 |
value={form.lastName}
|
| 66 |
onChange={(e) => setField('lastName', e.target.value)}
|
|
@@ -69,7 +122,7 @@ export default function ContactIdentityEditor({
|
|
| 69 |
/>
|
| 70 |
</div>
|
| 71 |
<div className="sm:col-span-2">
|
| 72 |
-
<label className="block text-xs font-medium text-slate-500
|
| 73 |
<Input
|
| 74 |
type="email"
|
| 75 |
value={form.email}
|
|
@@ -79,7 +132,7 @@ export default function ContactIdentityEditor({
|
|
| 79 |
/>
|
| 80 |
</div>
|
| 81 |
<div className="sm:col-span-2">
|
| 82 |
-
<label className="block text-xs font-medium text-slate-500
|
| 83 |
<Input
|
| 84 |
value={form.title}
|
| 85 |
onChange={(e) => setField('title', e.target.value)}
|
|
@@ -88,18 +141,25 @@ export default function ContactIdentityEditor({
|
|
| 88 |
/>
|
| 89 |
</div>
|
| 90 |
</div>
|
| 91 |
-
|
| 92 |
-
<
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
</div>
|
| 104 |
);
|
| 105 |
}
|
|
|
|
| 1 |
+
import React, { useEffect, useState, useRef } from 'react';
|
| 2 |
import { Loader2 } from 'lucide-react';
|
| 3 |
import { Button } from '@/components/ui/button';
|
| 4 |
import { Input } from '@/components/ui/input';
|
| 5 |
import { cn } from '@/lib/utils';
|
| 6 |
|
| 7 |
+
function baselineFromProps(firstName, lastName, email, title) {
|
| 8 |
+
return {
|
| 9 |
+
firstName: firstName || '',
|
| 10 |
+
lastName: lastName || '',
|
| 11 |
+
email: email || '',
|
| 12 |
+
title: title || '',
|
| 13 |
+
};
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
function formsEqual(a, b) {
|
| 17 |
+
return (
|
| 18 |
+
(a.firstName || '').trim() === (b.firstName || '').trim() &&
|
| 19 |
+
(a.lastName || '').trim() === (b.lastName || '').trim() &&
|
| 20 |
+
(a.email || '').trim() === (b.email || '').trim() &&
|
| 21 |
+
(a.title || '').trim() === (b.title || '').trim()
|
| 22 |
+
);
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
/**
|
| 26 |
+
* Editable person fields (first / last name, email, title).
|
| 27 |
+
* `autoSave`: debounced PATCH after typing pauses (no Save button).
|
| 28 |
+
* Otherwise: Save button commits all fields.
|
| 29 |
*/
|
| 30 |
export default function ContactIdentityEditor({
|
| 31 |
firstName = '',
|
|
|
|
| 36 |
disabled = false,
|
| 37 |
className,
|
| 38 |
heading = 'Contact details',
|
| 39 |
+
autoSave = false,
|
| 40 |
}) {
|
| 41 |
+
const [form, setForm] = useState(() => baselineFromProps(firstName, lastName, email, title));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
const [saving, setSaving] = useState(false);
|
| 43 |
+
const baselineRef = useRef(baselineFromProps(firstName, lastName, email, title));
|
| 44 |
|
| 45 |
useEffect(() => {
|
| 46 |
+
const b = baselineFromProps(firstName, lastName, email, title);
|
| 47 |
+
baselineRef.current = b;
|
| 48 |
+
setForm(b);
|
| 49 |
}, [firstName, lastName, email, title]);
|
| 50 |
|
| 51 |
const setField = (key, value) => setForm((f) => ({ ...f, [key]: value }));
|
|
|
|
| 65 |
}
|
| 66 |
};
|
| 67 |
|
| 68 |
+
useEffect(() => {
|
| 69 |
+
if (!autoSave || !onSave || disabled) return;
|
| 70 |
+
const baseline = baselineRef.current;
|
| 71 |
+
if (formsEqual(form, baseline)) return;
|
| 72 |
+
const t = setTimeout(async () => {
|
| 73 |
+
setSaving(true);
|
| 74 |
+
try {
|
| 75 |
+
await onSave({
|
| 76 |
+
first_name: form.firstName,
|
| 77 |
+
last_name: form.lastName,
|
| 78 |
+
email: form.email,
|
| 79 |
+
title: form.title,
|
| 80 |
+
});
|
| 81 |
+
baselineRef.current = {
|
| 82 |
+
firstName: form.firstName,
|
| 83 |
+
lastName: form.lastName,
|
| 84 |
+
email: form.email,
|
| 85 |
+
title: form.title,
|
| 86 |
+
};
|
| 87 |
+
} finally {
|
| 88 |
+
setSaving(false);
|
| 89 |
+
}
|
| 90 |
+
}, 650);
|
| 91 |
+
return () => clearTimeout(t);
|
| 92 |
+
}, [form, autoSave, disabled, onSave, firstName, lastName, email, title]);
|
| 93 |
+
|
| 94 |
return (
|
| 95 |
<div className={cn('rounded-xl border border-slate-200 bg-white p-4 mb-6', className)}>
|
| 96 |
+
<div className="mb-3 flex items-center justify-between gap-2">
|
| 97 |
+
<h4 className="text-sm font-semibold text-slate-800">{heading}</h4>
|
| 98 |
+
{autoSave && saving ? (
|
| 99 |
+
<span className="flex items-center gap-1 text-xs text-slate-500">
|
| 100 |
+
<Loader2 className="h-3 w-3 animate-spin shrink-0" aria-hidden />
|
| 101 |
+
Saving…
|
| 102 |
+
</span>
|
| 103 |
+
) : null}
|
| 104 |
+
</div>
|
| 105 |
+
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
|
| 106 |
<div>
|
| 107 |
+
<label className="mb-1 block text-xs font-medium text-slate-500">First name</label>
|
| 108 |
<Input
|
| 109 |
value={form.firstName}
|
| 110 |
onChange={(e) => setField('firstName', e.target.value)}
|
|
|
|
| 113 |
/>
|
| 114 |
</div>
|
| 115 |
<div>
|
| 116 |
+
<label className="mb-1 block text-xs font-medium text-slate-500">Last name</label>
|
| 117 |
<Input
|
| 118 |
value={form.lastName}
|
| 119 |
onChange={(e) => setField('lastName', e.target.value)}
|
|
|
|
| 122 |
/>
|
| 123 |
</div>
|
| 124 |
<div className="sm:col-span-2">
|
| 125 |
+
<label className="mb-1 block text-xs font-medium text-slate-500">Email</label>
|
| 126 |
<Input
|
| 127 |
type="email"
|
| 128 |
value={form.email}
|
|
|
|
| 132 |
/>
|
| 133 |
</div>
|
| 134 |
<div className="sm:col-span-2">
|
| 135 |
+
<label className="mb-1 block text-xs font-medium text-slate-500">Title</label>
|
| 136 |
<Input
|
| 137 |
value={form.title}
|
| 138 |
onChange={(e) => setField('title', e.target.value)}
|
|
|
|
| 141 |
/>
|
| 142 |
</div>
|
| 143 |
</div>
|
| 144 |
+
{!autoSave ? (
|
| 145 |
+
<div className="mt-4">
|
| 146 |
+
<Button
|
| 147 |
+
type="button"
|
| 148 |
+
className="w-full sm:w-auto"
|
| 149 |
+
onClick={handleSave}
|
| 150 |
+
disabled={disabled || saving}
|
| 151 |
+
>
|
| 152 |
+
{saving ? (
|
| 153 |
+
<>
|
| 154 |
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
|
| 155 |
+
Saving…
|
| 156 |
+
</>
|
| 157 |
+
) : (
|
| 158 |
+
'Save'
|
| 159 |
+
)}
|
| 160 |
+
</Button>
|
| 161 |
+
</div>
|
| 162 |
+
) : null}
|
| 163 |
</div>
|
| 164 |
);
|
| 165 |
}
|
frontend/src/pages/Contacts.jsx
CHANGED
|
@@ -1100,6 +1100,7 @@ export default function Contacts() {
|
|
| 1100 |
title={selectedContactDetails.title || ''}
|
| 1101 |
onSave={(patch) => patchContact(selectedContact.id, patch)}
|
| 1102 |
disabled={!selectedContact}
|
|
|
|
| 1103 |
/>
|
| 1104 |
)}
|
| 1105 |
|
|
@@ -1117,6 +1118,7 @@ export default function Contacts() {
|
|
| 1117 |
onFetch={enrichContactFromGpt}
|
| 1118 |
fetchLoading={enrichLoading}
|
| 1119 |
fetchError={enrichError}
|
|
|
|
| 1120 |
/>
|
| 1121 |
)}
|
| 1122 |
</div>
|
|
|
|
| 1100 |
title={selectedContactDetails.title || ''}
|
| 1101 |
onSave={(patch) => patchContact(selectedContact.id, patch)}
|
| 1102 |
disabled={!selectedContact}
|
| 1103 |
+
autoSave
|
| 1104 |
/>
|
| 1105 |
)}
|
| 1106 |
|
|
|
|
| 1118 |
onFetch={enrichContactFromGpt}
|
| 1119 |
fetchLoading={enrichLoading}
|
| 1120 |
fetchError={enrichError}
|
| 1121 |
+
autoSave
|
| 1122 |
/>
|
| 1123 |
)}
|
| 1124 |
</div>
|
frontend/src/pages/Deals.jsx
CHANGED
|
@@ -1,8 +1,7 @@
|
|
| 1 |
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
| 2 |
import { Link } from 'react-router-dom';
|
| 3 |
-
import { GitBranch, Loader2, LayoutGrid, MessageSquare
|
| 4 |
import { Button } from '@/components/ui/button';
|
| 5 |
-
import { Input } from '@/components/ui/input';
|
| 6 |
import { Select, SelectTrigger, SelectContent, SelectItem } from '@/components/ui/select';
|
| 7 |
import AppShell from '@/components/layout/AppShell';
|
| 8 |
import { apiFetch } from '@/lib/api';
|
|
@@ -86,13 +85,6 @@ function isRowUiTarget(e) {
|
|
| 86 |
);
|
| 87 |
}
|
| 88 |
|
| 89 |
-
function isoToDateInput(iso) {
|
| 90 |
-
if (!iso) return '';
|
| 91 |
-
const d = new Date(iso);
|
| 92 |
-
if (Number.isNaN(d.getTime())) return '';
|
| 93 |
-
return d.toISOString().slice(0, 10);
|
| 94 |
-
}
|
| 95 |
-
|
| 96 |
/** Maps stored probability to Select value: __clear__ or "10"…"100" (nearest 10, min 10). */
|
| 97 |
function closeProbabilitySelectValue(p) {
|
| 98 |
if (p == null || p === '') return '__clear__';
|
|
@@ -150,8 +142,6 @@ function GroupedDealTbody({
|
|
| 150 |
barClassName,
|
| 151 |
headerContent,
|
| 152 |
deals,
|
| 153 |
-
tableEditRowId,
|
| 154 |
-
setTableEditRowId,
|
| 155 |
patchDeal,
|
| 156 |
updateStage,
|
| 157 |
openDeal,
|
|
@@ -174,8 +164,6 @@ function GroupedDealTbody({
|
|
| 174 |
<DealRow
|
| 175 |
key={deal.id}
|
| 176 |
deal={deal}
|
| 177 |
-
tableEditRowId={tableEditRowId}
|
| 178 |
-
setTableEditRowId={setTableEditRowId}
|
| 179 |
patchDeal={patchDeal}
|
| 180 |
updateStage={updateStage}
|
| 181 |
openDeal={openDeal}
|
|
@@ -372,17 +360,7 @@ function PipelineBoard({ columns, openDeal, patchDeal, createDeal, createBusy })
|
|
| 372 |
);
|
| 373 |
}
|
| 374 |
|
| 375 |
-
function DealRow({
|
| 376 |
-
deal,
|
| 377 |
-
tableEditRowId,
|
| 378 |
-
setTableEditRowId,
|
| 379 |
-
patchDeal,
|
| 380 |
-
updateStage,
|
| 381 |
-
openDeal,
|
| 382 |
-
isAdmin,
|
| 383 |
-
currentUserId,
|
| 384 |
-
tenantMembers,
|
| 385 |
-
}) {
|
| 386 |
const meta = stageMeta(deal.stage);
|
| 387 |
const safeStage = STAGES.some((s) => s.value === deal.stage) ? deal.stage : 'new';
|
| 388 |
const closeSel = closeProbabilitySelectValue(deal.close_probability);
|
|
@@ -393,62 +371,26 @@ function DealRow({
|
|
| 393 |
tabIndex={0}
|
| 394 |
onClick={(e) => {
|
| 395 |
if (isRowUiTarget(e)) return;
|
| 396 |
-
setTableEditRowId(null);
|
| 397 |
openDeal(deal);
|
| 398 |
}}
|
| 399 |
onKeyDown={(e) => {
|
| 400 |
if (isRowUiTarget(e)) return;
|
| 401 |
if (e.key === 'Enter' || e.key === ' ') {
|
| 402 |
e.preventDefault();
|
| 403 |
-
setTableEditRowId(null);
|
| 404 |
openDeal(deal);
|
| 405 |
}
|
| 406 |
}}
|
| 407 |
className="border-b border-slate-100 hover:bg-violet-50/40 cursor-pointer"
|
| 408 |
>
|
| 409 |
-
<td className="px-
|
| 410 |
-
<
|
| 411 |
-
<input type="checkbox" className="rounded border-slate-300" />
|
| 412 |
-
<Button
|
| 413 |
-
type="button"
|
| 414 |
-
variant="ghost"
|
| 415 |
-
size="icon"
|
| 416 |
-
className={cn(
|
| 417 |
-
'h-7 w-7 text-slate-500 hover:text-violet-700',
|
| 418 |
-
tableEditRowId === deal.id && 'bg-violet-100 text-violet-800 hover:bg-violet-100'
|
| 419 |
-
)}
|
| 420 |
-
onClick={(e) => {
|
| 421 |
-
e.stopPropagation();
|
| 422 |
-
const tr = e.currentTarget.closest('tr');
|
| 423 |
-
if (tableEditRowId === deal.id) {
|
| 424 |
-
setTableEditRowId(null);
|
| 425 |
-
return;
|
| 426 |
-
}
|
| 427 |
-
setTableEditRowId(deal.id);
|
| 428 |
-
requestAnimationFrame(() => focusFirstEditableInRow(tr));
|
| 429 |
-
}}
|
| 430 |
-
aria-label={
|
| 431 |
-
tableEditRowId === deal.id
|
| 432 |
-
? `Stop editing row for ${deal.name || 'deal'}`
|
| 433 |
-
: `Edit fields in row for ${deal.name || 'deal'}`
|
| 434 |
-
}
|
| 435 |
-
>
|
| 436 |
-
<Pencil className="h-3.5 w-3.5" aria-hidden />
|
| 437 |
-
</Button>
|
| 438 |
-
</div>
|
| 439 |
</td>
|
| 440 |
-
<td className="px-3 py-2 align-top max-w-[220px] font-medium">
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
/>
|
| 447 |
-
) : (
|
| 448 |
-
<div className="min-h-[2rem] py-1 text-sm font-medium text-slate-900 truncate">
|
| 449 |
-
{deal.name || '—'}
|
| 450 |
-
</div>
|
| 451 |
-
)}
|
| 452 |
</td>
|
| 453 |
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
|
| 454 |
<Select value={safeStage} onValueChange={(v) => updateStage(deal.id, v)}>
|
|
@@ -581,7 +523,7 @@ function DealRow({
|
|
| 581 |
)}
|
| 582 |
</td>
|
| 583 |
<td
|
| 584 |
-
className="px-2 py-2 align-top tabular-nums w-[
|
| 585 |
onClick={(e) => e.stopPropagation()}
|
| 586 |
>
|
| 587 |
<EditableCurrencyCell
|
|
@@ -623,43 +565,19 @@ function DealRow({
|
|
| 623 |
</SelectContent>
|
| 624 |
</Select>
|
| 625 |
</td>
|
| 626 |
-
<td className="px-3 py-2 align-top max-w-[180px]">
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
/>
|
| 633 |
-
) : (
|
| 634 |
-
<div className="min-h-[2rem] py-1">
|
| 635 |
-
{deal.contact_display ? (
|
| 636 |
-
<span className="inline-flex rounded-md bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-800">
|
| 637 |
-
{deal.contact_display}
|
| 638 |
-
</span>
|
| 639 |
-
) : (
|
| 640 |
-
<span className="text-slate-500">—</span>
|
| 641 |
-
)}
|
| 642 |
-
</div>
|
| 643 |
-
)}
|
| 644 |
</td>
|
| 645 |
-
<td className="px-3 py-2 align-top max-w-[200px]">
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
/>
|
| 652 |
-
) : (
|
| 653 |
-
<div className="min-h-[2rem] py-1">
|
| 654 |
-
{deal.account_name ? (
|
| 655 |
-
<span className="inline-flex rounded-md bg-violet-50 px-2 py-0.5 text-xs font-medium text-violet-800">
|
| 656 |
-
{deal.account_name}
|
| 657 |
-
</span>
|
| 658 |
-
) : (
|
| 659 |
-
<span className="text-slate-500">—</span>
|
| 660 |
-
)}
|
| 661 |
-
</div>
|
| 662 |
-
)}
|
| 663 |
</td>
|
| 664 |
<td className="px-3 py-2 align-top" onClick={(e) => e.stopPropagation()}>
|
| 665 |
<EditableDateCell
|
|
@@ -737,13 +655,10 @@ export default function Deals() {
|
|
| 737 |
const [seedBusy, setSeedBusy] = useState(false);
|
| 738 |
const [panelOpen, setPanelOpen] = useState(false);
|
| 739 |
const [dealDetail, setDealDetail] = useState(null);
|
| 740 |
-
const [tableEditRowId, setTableEditRowId] = useState(null);
|
| 741 |
const [dealsView, setDealsView] = useState('main');
|
| 742 |
const [createBusy, setCreateBusy] = useState(false);
|
| 743 |
const [companyFetchLoading, setCompanyFetchLoading] = useState(false);
|
| 744 |
const [companyFetchError, setCompanyFetchError] = useState('');
|
| 745 |
-
const [dealPanelForm, setDealPanelForm] = useState(null);
|
| 746 |
-
const [dealPanelSaving, setDealPanelSaving] = useState(false);
|
| 747 |
const [me, setMe] = useState(null);
|
| 748 |
const [tenantMembers, setTenantMembers] = useState([]);
|
| 749 |
|
|
@@ -795,10 +710,6 @@ export default function Deals() {
|
|
| 795 |
return () => clearTimeout(t);
|
| 796 |
}, [fetchDeals]);
|
| 797 |
|
| 798 |
-
useEffect(() => {
|
| 799 |
-
setTableEditRowId(null);
|
| 800 |
-
}, [dealsView]);
|
| 801 |
-
|
| 802 |
const dealsByStage = useMemo(() => {
|
| 803 |
return STAGES.map((s) => ({
|
| 804 |
...s,
|
|
@@ -909,8 +820,7 @@ export default function Deals() {
|
|
| 909 |
throw new Error(msg || 'Could not create deal');
|
| 910 |
}
|
| 911 |
await fetchDeals();
|
| 912 |
-
|
| 913 |
-
openDeal(data, { keepTableEdit: true });
|
| 914 |
requestAnimationFrame(() => {
|
| 915 |
requestAnimationFrame(() => {
|
| 916 |
const tr = document.querySelector(`tr[data-deal-id="${data.id}"]`);
|
|
@@ -996,11 +906,9 @@ export default function Deals() {
|
|
| 996 |
|
| 997 |
const updateStage = (dealId, stage) => patchDeal(dealId, { stage });
|
| 998 |
|
| 999 |
-
const openDeal = async (deal
|
| 1000 |
-
if (!opts.keepTableEdit) setTableEditRowId(null);
|
| 1001 |
setPanelOpen(true);
|
| 1002 |
setDealDetail(deal);
|
| 1003 |
-
setDealPanelForm(null);
|
| 1004 |
try {
|
| 1005 |
const res = await apiFetch(`/api/deals/${deal.id}`);
|
| 1006 |
if (res.ok) {
|
|
@@ -1012,58 +920,14 @@ export default function Deals() {
|
|
| 1012 |
}
|
| 1013 |
};
|
| 1014 |
|
| 1015 |
-
useEffect(() => {
|
| 1016 |
-
if (!dealDetail) {
|
| 1017 |
-
setDealPanelForm(null);
|
| 1018 |
-
return;
|
| 1019 |
-
}
|
| 1020 |
-
setDealPanelForm({
|
| 1021 |
-
name: dealDetail.name || '',
|
| 1022 |
-
deal_value: dealDetail.deal_value != null ? String(dealDetail.deal_value) : '',
|
| 1023 |
-
revenue_type: revenueTypeSelectValue(dealDetail),
|
| 1024 |
-
close_prob: closeProbabilitySelectValue(dealDetail.close_probability),
|
| 1025 |
-
expected_close: isoToDateInput(dealDetail.expected_close_date),
|
| 1026 |
-
contact_display: dealDetail.contact_display || '',
|
| 1027 |
-
last_interaction: isoToDateInput(dealDetail.last_interaction_at),
|
| 1028 |
-
});
|
| 1029 |
-
}, [dealDetail]);
|
| 1030 |
-
|
| 1031 |
-
const saveDealPanel = async () => {
|
| 1032 |
-
if (!dealDetail || !dealPanelForm) return;
|
| 1033 |
-
setDealPanelSaving(true);
|
| 1034 |
-
try {
|
| 1035 |
-
let deal_value = null;
|
| 1036 |
-
if (dealPanelForm.deal_value.trim() !== '') {
|
| 1037 |
-
const n = Math.round(Number(dealPanelForm.deal_value));
|
| 1038 |
-
if (Number.isFinite(n)) deal_value = n;
|
| 1039 |
-
}
|
| 1040 |
-
let close_probability = 0;
|
| 1041 |
-
if (dealPanelForm.close_prob !== '__clear__') {
|
| 1042 |
-
close_probability = Number(dealPanelForm.close_prob);
|
| 1043 |
-
if (!Number.isFinite(close_probability)) close_probability = 0;
|
| 1044 |
-
}
|
| 1045 |
-
await patchDeal(dealDetail.id, {
|
| 1046 |
-
name: dealPanelForm.name.trim() || 'Untitled deal',
|
| 1047 |
-
deal_value,
|
| 1048 |
-
revenue_type: dealPanelForm.revenue_type,
|
| 1049 |
-
close_probability,
|
| 1050 |
-
expected_close_date: dealPanelForm.expected_close.trim() ? dealPanelForm.expected_close.trim() : null,
|
| 1051 |
-
contact_display: dealPanelForm.contact_display.trim(),
|
| 1052 |
-
last_interaction_at: dealPanelForm.last_interaction.trim()
|
| 1053 |
-
? dealPanelForm.last_interaction.trim()
|
| 1054 |
-
: null,
|
| 1055 |
-
});
|
| 1056 |
-
} finally {
|
| 1057 |
-
setDealPanelSaving(false);
|
| 1058 |
-
}
|
| 1059 |
-
};
|
| 1060 |
-
|
| 1061 |
const closePanel = () => {
|
| 1062 |
-
setTableEditRowId(null);
|
| 1063 |
setPanelOpen(false);
|
| 1064 |
setDealDetail(null);
|
| 1065 |
};
|
| 1066 |
|
|
|
|
|
|
|
|
|
|
| 1067 |
return (
|
| 1068 |
<AppShell
|
| 1069 |
title="Deals"
|
|
@@ -1161,11 +1025,11 @@ export default function Deals() {
|
|
| 1161 |
<table className="w-full text-sm min-w-[1040px]">
|
| 1162 |
<thead>
|
| 1163 |
<tr className="bg-slate-50 text-left text-slate-600 border-b border-slate-200">
|
| 1164 |
-
<th className="px-2 py-2 w-
|
| 1165 |
<th className="px-3 py-2 font-medium">Deal</th>
|
| 1166 |
<th className="px-3 py-2 font-medium">Stage</th>
|
| 1167 |
<th className="px-3 py-2 font-medium">Owner</th>
|
| 1168 |
-
<th className="px-2 py-2 font-medium text-right w-[
|
| 1169 |
Deal value
|
| 1170 |
</th>
|
| 1171 |
<th className="px-3 py-2 font-medium">Revenue</th>
|
|
@@ -1184,8 +1048,6 @@ export default function Deals() {
|
|
| 1184 |
<DealRow
|
| 1185 |
key={deal.id}
|
| 1186 |
deal={deal}
|
| 1187 |
-
tableEditRowId={tableEditRowId}
|
| 1188 |
-
setTableEditRowId={setTableEditRowId}
|
| 1189 |
patchDeal={patchDeal}
|
| 1190 |
updateStage={updateStage}
|
| 1191 |
openDeal={openDeal}
|
|
@@ -1220,8 +1082,6 @@ export default function Deals() {
|
|
| 1220 |
</div>
|
| 1221 |
}
|
| 1222 |
deals={group.deals}
|
| 1223 |
-
tableEditRowId={tableEditRowId}
|
| 1224 |
-
setTableEditRowId={setTableEditRowId}
|
| 1225 |
patchDeal={patchDeal}
|
| 1226 |
updateStage={updateStage}
|
| 1227 |
openDeal={openDeal}
|
|
@@ -1246,8 +1106,6 @@ export default function Deals() {
|
|
| 1246 |
/>
|
| 1247 |
}
|
| 1248 |
deals={group.deals}
|
| 1249 |
-
tableEditRowId={tableEditRowId}
|
| 1250 |
-
setTableEditRowId={setTableEditRowId}
|
| 1251 |
patchDeal={patchDeal}
|
| 1252 |
updateStage={updateStage}
|
| 1253 |
openDeal={openDeal}
|
|
@@ -1282,8 +1140,6 @@ export default function Deals() {
|
|
| 1282 |
</div>
|
| 1283 |
}
|
| 1284 |
deals={group.deals}
|
| 1285 |
-
tableEditRowId={tableEditRowId}
|
| 1286 |
-
setTableEditRowId={setTableEditRowId}
|
| 1287 |
patchDeal={patchDeal}
|
| 1288 |
updateStage={updateStage}
|
| 1289 |
openDeal={openDeal}
|
|
@@ -1304,18 +1160,18 @@ export default function Deals() {
|
|
| 1304 |
open={panelOpen && !!dealDetail}
|
| 1305 |
onClose={closePanel}
|
| 1306 |
title={
|
| 1307 |
-
|
| 1308 |
-
<
|
| 1309 |
-
|
| 1310 |
-
|
| 1311 |
-
|
|
|
|
| 1312 |
}
|
| 1313 |
-
className="text-lg font-semibold
|
| 1314 |
-
|
| 1315 |
-
aria-label="Deal name"
|
| 1316 |
/>
|
| 1317 |
) : (
|
| 1318 |
-
|
| 1319 |
)
|
| 1320 |
}
|
| 1321 |
subtitle={
|
|
@@ -1325,45 +1181,43 @@ export default function Deals() {
|
|
| 1325 |
}
|
| 1326 |
widthClassName="max-w-xl"
|
| 1327 |
>
|
| 1328 |
-
{dealDetail
|
| 1329 |
<div className="space-y-6 text-sm">
|
| 1330 |
<section className="rounded-xl border border-slate-200 bg-slate-50/50 p-4">
|
| 1331 |
<h4 className="text-sm font-semibold text-slate-800 mb-3">Deal information</h4>
|
| 1332 |
-
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3
|
| 1333 |
-
<div>
|
| 1334 |
<label className="block text-xs font-medium text-slate-500 mb-1">
|
| 1335 |
Deal value (USD)
|
| 1336 |
</label>
|
| 1337 |
-
<
|
| 1338 |
-
|
| 1339 |
-
|
| 1340 |
-
|
| 1341 |
-
|
| 1342 |
-
|
| 1343 |
-
|
| 1344 |
-
|
| 1345 |
-
|
| 1346 |
-
})
|
| 1347 |
-
}
|
| 1348 |
-
className="
|
| 1349 |
-
|
| 1350 |
/>
|
| 1351 |
</div>
|
| 1352 |
-
<div>
|
| 1353 |
<label className="block text-xs font-medium text-slate-500 mb-1">
|
| 1354 |
Revenue
|
| 1355 |
</label>
|
| 1356 |
<Select
|
| 1357 |
-
value={
|
| 1358 |
-
onValueChange={(v) =>
|
| 1359 |
-
setDealPanelForm((f) => ({ ...f, revenue_type: v }))
|
| 1360 |
-
}
|
| 1361 |
>
|
| 1362 |
<SelectTrigger className="h-10 border-slate-200 bg-white shadow-sm text-sm">
|
| 1363 |
<span className="font-medium">
|
| 1364 |
{
|
| 1365 |
REVENUE_TYPES.find(
|
| 1366 |
-
(t) => t.value ===
|
| 1367 |
)?.label
|
| 1368 |
}
|
| 1369 |
</span>
|
|
@@ -1377,21 +1231,23 @@ export default function Deals() {
|
|
| 1377 |
</SelectContent>
|
| 1378 |
</Select>
|
| 1379 |
</div>
|
| 1380 |
-
<div>
|
| 1381 |
<label className="block text-xs font-medium text-slate-500 mb-1">
|
| 1382 |
Close probability
|
| 1383 |
</label>
|
| 1384 |
<Select
|
| 1385 |
-
value={
|
| 1386 |
-
onValueChange={(v) =>
|
| 1387 |
-
|
| 1388 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1389 |
>
|
| 1390 |
<SelectTrigger className="h-10 border-slate-200 bg-white shadow-sm">
|
| 1391 |
<span className="tabular-nums">
|
| 1392 |
-
{
|
| 1393 |
-
? '—'
|
| 1394 |
-
: `${dealPanelForm.close_prob}%`}
|
| 1395 |
</span>
|
| 1396 |
</SelectTrigger>
|
| 1397 |
<SelectContent>
|
|
@@ -1406,70 +1262,48 @@ export default function Deals() {
|
|
| 1406 |
</SelectContent>
|
| 1407 |
</Select>
|
| 1408 |
</div>
|
| 1409 |
-
<div>
|
| 1410 |
<label className="block text-xs font-medium text-slate-500 mb-1">
|
| 1411 |
Expected close
|
| 1412 |
</label>
|
| 1413 |
-
<
|
| 1414 |
-
|
| 1415 |
-
|
| 1416 |
-
|
| 1417 |
-
|
| 1418 |
-
|
| 1419 |
-
expected_close: e.target.value,
|
| 1420 |
-
}))
|
| 1421 |
}
|
| 1422 |
-
className="text-sm bg-white"
|
| 1423 |
/>
|
| 1424 |
</div>
|
| 1425 |
-
<div>
|
| 1426 |
<label className="block text-xs font-medium text-slate-500 mb-1">
|
| 1427 |
Last interaction
|
| 1428 |
</label>
|
| 1429 |
-
<
|
| 1430 |
-
|
| 1431 |
-
|
| 1432 |
-
|
| 1433 |
-
|
| 1434 |
-
|
| 1435 |
-
last_interaction: e.target.value,
|
| 1436 |
-
}))
|
| 1437 |
}
|
| 1438 |
-
className="text-sm bg-white"
|
| 1439 |
/>
|
| 1440 |
</div>
|
| 1441 |
-
<div className="sm:col-span-2">
|
| 1442 |
<label className="block text-xs font-medium text-slate-500 mb-1">
|
| 1443 |
Contact (display)
|
| 1444 |
</label>
|
| 1445 |
-
<
|
| 1446 |
-
value={
|
| 1447 |
-
|
| 1448 |
-
|
| 1449 |
-
...f,
|
| 1450 |
-
contact_display: e.target.value,
|
| 1451 |
-
}))
|
| 1452 |
}
|
| 1453 |
-
className="
|
| 1454 |
-
|
| 1455 |
/>
|
| 1456 |
</div>
|
| 1457 |
</div>
|
| 1458 |
-
<Button
|
| 1459 |
-
type="button"
|
| 1460 |
-
className="w-full sm:w-auto"
|
| 1461 |
-
onClick={saveDealPanel}
|
| 1462 |
-
disabled={dealPanelSaving}
|
| 1463 |
-
>
|
| 1464 |
-
{dealPanelSaving ? (
|
| 1465 |
-
<>
|
| 1466 |
-
<Loader2 className="h-4 w-4 animate-spin mr-2" aria-hidden />
|
| 1467 |
-
Saving…
|
| 1468 |
-
</>
|
| 1469 |
-
) : (
|
| 1470 |
-
'Save deal'
|
| 1471 |
-
)}
|
| 1472 |
-
</Button>
|
| 1473 |
{dealDetail.source_campaign_name ? (
|
| 1474 |
<p className="mt-3 text-xs text-slate-500">
|
| 1475 |
Source campaign:{' '}
|
|
@@ -1495,6 +1329,7 @@ export default function Deals() {
|
|
| 1495 |
patchLinkedContact(dealDetail.linked_contact.id, patch)
|
| 1496 |
}
|
| 1497 |
className="mb-0"
|
|
|
|
| 1498 |
/>
|
| 1499 |
<div className="mt-2">
|
| 1500 |
<Button asChild variant="outline" size="sm">
|
|
@@ -1523,10 +1358,11 @@ export default function Deals() {
|
|
| 1523 |
onFetch={fetchDealCompanyAi}
|
| 1524 |
fetchLoading={companyFetchLoading}
|
| 1525 |
fetchError={companyFetchError}
|
|
|
|
| 1526 |
/>
|
| 1527 |
</section>
|
| 1528 |
</div>
|
| 1529 |
-
)}
|
| 1530 |
</SlideOverPanel>
|
| 1531 |
</AppShell>
|
| 1532 |
);
|
|
|
|
| 1 |
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
| 2 |
import { Link } from 'react-router-dom';
|
| 3 |
+
import { GitBranch, Loader2, LayoutGrid, MessageSquare } from 'lucide-react';
|
| 4 |
import { Button } from '@/components/ui/button';
|
|
|
|
| 5 |
import { Select, SelectTrigger, SelectContent, SelectItem } from '@/components/ui/select';
|
| 6 |
import AppShell from '@/components/layout/AppShell';
|
| 7 |
import { apiFetch } from '@/lib/api';
|
|
|
|
| 85 |
);
|
| 86 |
}
|
| 87 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
/** Maps stored probability to Select value: __clear__ or "10"…"100" (nearest 10, min 10). */
|
| 89 |
function closeProbabilitySelectValue(p) {
|
| 90 |
if (p == null || p === '') return '__clear__';
|
|
|
|
| 142 |
barClassName,
|
| 143 |
headerContent,
|
| 144 |
deals,
|
|
|
|
|
|
|
| 145 |
patchDeal,
|
| 146 |
updateStage,
|
| 147 |
openDeal,
|
|
|
|
| 164 |
<DealRow
|
| 165 |
key={deal.id}
|
| 166 |
deal={deal}
|
|
|
|
|
|
|
| 167 |
patchDeal={patchDeal}
|
| 168 |
updateStage={updateStage}
|
| 169 |
openDeal={openDeal}
|
|
|
|
| 360 |
);
|
| 361 |
}
|
| 362 |
|
| 363 |
+
function DealRow({ deal, patchDeal, updateStage, openDeal, isAdmin, currentUserId, tenantMembers }) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 364 |
const meta = stageMeta(deal.stage);
|
| 365 |
const safeStage = STAGES.some((s) => s.value === deal.stage) ? deal.stage : 'new';
|
| 366 |
const closeSel = closeProbabilitySelectValue(deal.close_probability);
|
|
|
|
| 371 |
tabIndex={0}
|
| 372 |
onClick={(e) => {
|
| 373 |
if (isRowUiTarget(e)) return;
|
|
|
|
| 374 |
openDeal(deal);
|
| 375 |
}}
|
| 376 |
onKeyDown={(e) => {
|
| 377 |
if (isRowUiTarget(e)) return;
|
| 378 |
if (e.key === 'Enter' || e.key === ' ') {
|
| 379 |
e.preventDefault();
|
|
|
|
| 380 |
openDeal(deal);
|
| 381 |
}
|
| 382 |
}}
|
| 383 |
className="border-b border-slate-100 hover:bg-violet-50/40 cursor-pointer"
|
| 384 |
>
|
| 385 |
+
<td className="px-2 py-2 w-10 text-center align-middle" onClick={(e) => e.stopPropagation()}>
|
| 386 |
+
<input type="checkbox" className="rounded border-slate-300" aria-label="Select row" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 387 |
</td>
|
| 388 |
+
<td className="px-3 py-2 align-top max-w-[220px] font-medium" onClick={(e) => e.stopPropagation()}>
|
| 389 |
+
<EditableCell
|
| 390 |
+
value={deal.name || ''}
|
| 391 |
+
onCommit={(v) => patchDeal(deal.id, { name: v })}
|
| 392 |
+
inputClassName="font-medium text-slate-900"
|
| 393 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 394 |
</td>
|
| 395 |
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
|
| 396 |
<Select value={safeStage} onValueChange={(v) => updateStage(deal.id, v)}>
|
|
|
|
| 523 |
)}
|
| 524 |
</td>
|
| 525 |
<td
|
| 526 |
+
className="px-2 py-2 align-top tabular-nums w-[min(11rem,100%)] min-w-[10rem] max-w-[12rem] shrink-0"
|
| 527 |
onClick={(e) => e.stopPropagation()}
|
| 528 |
>
|
| 529 |
<EditableCurrencyCell
|
|
|
|
| 565 |
</SelectContent>
|
| 566 |
</Select>
|
| 567 |
</td>
|
| 568 |
+
<td className="px-3 py-2 align-top max-w-[180px]" onClick={(e) => e.stopPropagation()}>
|
| 569 |
+
<EditableCell
|
| 570 |
+
value={deal.contact_display || ''}
|
| 571 |
+
onCommit={(v) => patchDeal(deal.id, { contact_display: v })}
|
| 572 |
+
inputClassName="text-xs"
|
| 573 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 574 |
</td>
|
| 575 |
+
<td className="px-3 py-2 align-top max-w-[200px]" onClick={(e) => e.stopPropagation()}>
|
| 576 |
+
<EditableCell
|
| 577 |
+
value={deal.account_name || ''}
|
| 578 |
+
onCommit={(v) => patchDeal(deal.id, { account_name: v })}
|
| 579 |
+
inputClassName="text-xs"
|
| 580 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 581 |
</td>
|
| 582 |
<td className="px-3 py-2 align-top" onClick={(e) => e.stopPropagation()}>
|
| 583 |
<EditableDateCell
|
|
|
|
| 655 |
const [seedBusy, setSeedBusy] = useState(false);
|
| 656 |
const [panelOpen, setPanelOpen] = useState(false);
|
| 657 |
const [dealDetail, setDealDetail] = useState(null);
|
|
|
|
| 658 |
const [dealsView, setDealsView] = useState('main');
|
| 659 |
const [createBusy, setCreateBusy] = useState(false);
|
| 660 |
const [companyFetchLoading, setCompanyFetchLoading] = useState(false);
|
| 661 |
const [companyFetchError, setCompanyFetchError] = useState('');
|
|
|
|
|
|
|
| 662 |
const [me, setMe] = useState(null);
|
| 663 |
const [tenantMembers, setTenantMembers] = useState([]);
|
| 664 |
|
|
|
|
| 710 |
return () => clearTimeout(t);
|
| 711 |
}, [fetchDeals]);
|
| 712 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 713 |
const dealsByStage = useMemo(() => {
|
| 714 |
return STAGES.map((s) => ({
|
| 715 |
...s,
|
|
|
|
| 820 |
throw new Error(msg || 'Could not create deal');
|
| 821 |
}
|
| 822 |
await fetchDeals();
|
| 823 |
+
openDeal(data);
|
|
|
|
| 824 |
requestAnimationFrame(() => {
|
| 825 |
requestAnimationFrame(() => {
|
| 826 |
const tr = document.querySelector(`tr[data-deal-id="${data.id}"]`);
|
|
|
|
| 906 |
|
| 907 |
const updateStage = (dealId, stage) => patchDeal(dealId, { stage });
|
| 908 |
|
| 909 |
+
const openDeal = async (deal) => {
|
|
|
|
| 910 |
setPanelOpen(true);
|
| 911 |
setDealDetail(deal);
|
|
|
|
| 912 |
try {
|
| 913 |
const res = await apiFetch(`/api/deals/${deal.id}`);
|
| 914 |
if (res.ok) {
|
|
|
|
| 920 |
}
|
| 921 |
};
|
| 922 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 923 |
const closePanel = () => {
|
|
|
|
| 924 |
setPanelOpen(false);
|
| 925 |
setDealDetail(null);
|
| 926 |
};
|
| 927 |
|
| 928 |
+
const dealPanelCloseSel =
|
| 929 |
+
dealDetail != null ? closeProbabilitySelectValue(dealDetail.close_probability) : '__clear__';
|
| 930 |
+
|
| 931 |
return (
|
| 932 |
<AppShell
|
| 933 |
title="Deals"
|
|
|
|
| 1025 |
<table className="w-full text-sm min-w-[1040px]">
|
| 1026 |
<thead>
|
| 1027 |
<tr className="bg-slate-50 text-left text-slate-600 border-b border-slate-200">
|
| 1028 |
+
<th className="px-2 py-2 w-10" aria-label="Select row" />
|
| 1029 |
<th className="px-3 py-2 font-medium">Deal</th>
|
| 1030 |
<th className="px-3 py-2 font-medium">Stage</th>
|
| 1031 |
<th className="px-3 py-2 font-medium">Owner</th>
|
| 1032 |
+
<th className="px-2 py-2 font-medium text-right w-[min(11rem,100%)] min-w-[10rem] max-w-[12rem]">
|
| 1033 |
Deal value
|
| 1034 |
</th>
|
| 1035 |
<th className="px-3 py-2 font-medium">Revenue</th>
|
|
|
|
| 1048 |
<DealRow
|
| 1049 |
key={deal.id}
|
| 1050 |
deal={deal}
|
|
|
|
|
|
|
| 1051 |
patchDeal={patchDeal}
|
| 1052 |
updateStage={updateStage}
|
| 1053 |
openDeal={openDeal}
|
|
|
|
| 1082 |
</div>
|
| 1083 |
}
|
| 1084 |
deals={group.deals}
|
|
|
|
|
|
|
| 1085 |
patchDeal={patchDeal}
|
| 1086 |
updateStage={updateStage}
|
| 1087 |
openDeal={openDeal}
|
|
|
|
| 1106 |
/>
|
| 1107 |
}
|
| 1108 |
deals={group.deals}
|
|
|
|
|
|
|
| 1109 |
patchDeal={patchDeal}
|
| 1110 |
updateStage={updateStage}
|
| 1111 |
openDeal={openDeal}
|
|
|
|
| 1140 |
</div>
|
| 1141 |
}
|
| 1142 |
deals={group.deals}
|
|
|
|
|
|
|
| 1143 |
patchDeal={patchDeal}
|
| 1144 |
updateStage={updateStage}
|
| 1145 |
openDeal={openDeal}
|
|
|
|
| 1160 |
open={panelOpen && !!dealDetail}
|
| 1161 |
onClose={closePanel}
|
| 1162 |
title={
|
| 1163 |
+
dealDetail ? (
|
| 1164 |
+
<EditableCell
|
| 1165 |
+
key={dealDetail.id}
|
| 1166 |
+
value={dealDetail.name || ''}
|
| 1167 |
+
onCommit={(v) =>
|
| 1168 |
+
patchDeal(dealDetail.id, { name: (v || '').trim() || 'Untitled deal' })
|
| 1169 |
}
|
| 1170 |
+
className="text-lg font-semibold"
|
| 1171 |
+
inputClassName="text-lg font-semibold text-slate-900 px-2 py-1.5 border border-slate-200 rounded-md bg-white shadow-sm w-full max-w-full"
|
|
|
|
| 1172 |
/>
|
| 1173 |
) : (
|
| 1174 |
+
'Deal'
|
| 1175 |
)
|
| 1176 |
}
|
| 1177 |
subtitle={
|
|
|
|
| 1181 |
}
|
| 1182 |
widthClassName="max-w-xl"
|
| 1183 |
>
|
| 1184 |
+
{dealDetail ? (
|
| 1185 |
<div className="space-y-6 text-sm">
|
| 1186 |
<section className="rounded-xl border border-slate-200 bg-slate-50/50 p-4">
|
| 1187 |
<h4 className="text-sm font-semibold text-slate-800 mb-3">Deal information</h4>
|
| 1188 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
| 1189 |
+
<div onClick={(e) => e.stopPropagation()}>
|
| 1190 |
<label className="block text-xs font-medium text-slate-500 mb-1">
|
| 1191 |
Deal value (USD)
|
| 1192 |
</label>
|
| 1193 |
+
<EditableCurrencyCell
|
| 1194 |
+
value={dealDetail.deal_value != null ? String(dealDetail.deal_value) : ''}
|
| 1195 |
+
onCommit={(v) => {
|
| 1196 |
+
if (v.trim() === '') {
|
| 1197 |
+
patchDeal(dealDetail.id, { deal_value: null });
|
| 1198 |
+
return;
|
| 1199 |
+
}
|
| 1200 |
+
const n = Math.round(Number(v));
|
| 1201 |
+
if (!Number.isFinite(n)) return;
|
| 1202 |
+
patchDeal(dealDetail.id, { deal_value: n });
|
| 1203 |
+
}}
|
| 1204 |
+
className="w-full bg-white rounded-md border border-slate-200 px-2 py-2"
|
| 1205 |
+
inputClassName="text-sm tabular-nums"
|
| 1206 |
/>
|
| 1207 |
</div>
|
| 1208 |
+
<div onClick={(e) => e.stopPropagation()}>
|
| 1209 |
<label className="block text-xs font-medium text-slate-500 mb-1">
|
| 1210 |
Revenue
|
| 1211 |
</label>
|
| 1212 |
<Select
|
| 1213 |
+
value={revenueTypeSelectValue(dealDetail)}
|
| 1214 |
+
onValueChange={(v) => patchDeal(dealDetail.id, { revenue_type: v })}
|
|
|
|
|
|
|
| 1215 |
>
|
| 1216 |
<SelectTrigger className="h-10 border-slate-200 bg-white shadow-sm text-sm">
|
| 1217 |
<span className="font-medium">
|
| 1218 |
{
|
| 1219 |
REVENUE_TYPES.find(
|
| 1220 |
+
(t) => t.value === revenueTypeSelectValue(dealDetail)
|
| 1221 |
)?.label
|
| 1222 |
}
|
| 1223 |
</span>
|
|
|
|
| 1231 |
</SelectContent>
|
| 1232 |
</Select>
|
| 1233 |
</div>
|
| 1234 |
+
<div onClick={(e) => e.stopPropagation()}>
|
| 1235 |
<label className="block text-xs font-medium text-slate-500 mb-1">
|
| 1236 |
Close probability
|
| 1237 |
</label>
|
| 1238 |
<Select
|
| 1239 |
+
value={dealPanelCloseSel}
|
| 1240 |
+
onValueChange={(v) => {
|
| 1241 |
+
if (v === '__clear__') {
|
| 1242 |
+
patchDeal(dealDetail.id, { close_probability: 0 });
|
| 1243 |
+
return;
|
| 1244 |
+
}
|
| 1245 |
+
patchDeal(dealDetail.id, { close_probability: Number(v) });
|
| 1246 |
+
}}
|
| 1247 |
>
|
| 1248 |
<SelectTrigger className="h-10 border-slate-200 bg-white shadow-sm">
|
| 1249 |
<span className="tabular-nums">
|
| 1250 |
+
{dealPanelCloseSel === '__clear__' ? '—' : `${dealPanelCloseSel}%`}
|
|
|
|
|
|
|
| 1251 |
</span>
|
| 1252 |
</SelectTrigger>
|
| 1253 |
<SelectContent>
|
|
|
|
| 1262 |
</SelectContent>
|
| 1263 |
</Select>
|
| 1264 |
</div>
|
| 1265 |
+
<div onClick={(e) => e.stopPropagation()}>
|
| 1266 |
<label className="block text-xs font-medium text-slate-500 mb-1">
|
| 1267 |
Expected close
|
| 1268 |
</label>
|
| 1269 |
+
<EditableDateCell
|
| 1270 |
+
value={dealDetail.expected_close_date}
|
| 1271 |
+
onCommit={(dateStr) =>
|
| 1272 |
+
patchDeal(dealDetail.id, {
|
| 1273 |
+
expected_close_date: dateStr || null,
|
| 1274 |
+
})
|
|
|
|
|
|
|
| 1275 |
}
|
| 1276 |
+
className="text-sm bg-white rounded-md border border-slate-200 px-2 py-2 w-full"
|
| 1277 |
/>
|
| 1278 |
</div>
|
| 1279 |
+
<div onClick={(e) => e.stopPropagation()}>
|
| 1280 |
<label className="block text-xs font-medium text-slate-500 mb-1">
|
| 1281 |
Last interaction
|
| 1282 |
</label>
|
| 1283 |
+
<EditableDateCell
|
| 1284 |
+
value={dealDetail.last_interaction_at}
|
| 1285 |
+
onCommit={(dateStr) =>
|
| 1286 |
+
patchDeal(dealDetail.id, {
|
| 1287 |
+
last_interaction_at: dateStr || null,
|
| 1288 |
+
})
|
|
|
|
|
|
|
| 1289 |
}
|
| 1290 |
+
className="text-sm bg-white rounded-md border border-slate-200 px-2 py-2 w-full"
|
| 1291 |
/>
|
| 1292 |
</div>
|
| 1293 |
+
<div className="sm:col-span-2" onClick={(e) => e.stopPropagation()}>
|
| 1294 |
<label className="block text-xs font-medium text-slate-500 mb-1">
|
| 1295 |
Contact (display)
|
| 1296 |
</label>
|
| 1297 |
+
<EditableCell
|
| 1298 |
+
value={dealDetail.contact_display || ''}
|
| 1299 |
+
onCommit={(v) =>
|
| 1300 |
+
patchDeal(dealDetail.id, { contact_display: v.trim() })
|
|
|
|
|
|
|
|
|
|
| 1301 |
}
|
| 1302 |
+
className="bg-white rounded-md border border-slate-200 px-2 py-2 w-full"
|
| 1303 |
+
inputClassName="text-sm"
|
| 1304 |
/>
|
| 1305 |
</div>
|
| 1306 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1307 |
{dealDetail.source_campaign_name ? (
|
| 1308 |
<p className="mt-3 text-xs text-slate-500">
|
| 1309 |
Source campaign:{' '}
|
|
|
|
| 1329 |
patchLinkedContact(dealDetail.linked_contact.id, patch)
|
| 1330 |
}
|
| 1331 |
className="mb-0"
|
| 1332 |
+
autoSave
|
| 1333 |
/>
|
| 1334 |
<div className="mt-2">
|
| 1335 |
<Button asChild variant="outline" size="sm">
|
|
|
|
| 1358 |
onFetch={fetchDealCompanyAi}
|
| 1359 |
fetchLoading={companyFetchLoading}
|
| 1360 |
fetchError={companyFetchError}
|
| 1361 |
+
autoSave
|
| 1362 |
/>
|
| 1363 |
</section>
|
| 1364 |
</div>
|
| 1365 |
+
) : null}
|
| 1366 |
</SlideOverPanel>
|
| 1367 |
</AppShell>
|
| 1368 |
);
|
frontend/src/pages/Leads.jsx
CHANGED
|
@@ -699,6 +699,7 @@ export default function Leads() {
|
|
| 699 |
email={selected.email || ''}
|
| 700 |
title={selected.title || ''}
|
| 701 |
onSave={(patch) => patchLead(selected.id, patch)}
|
|
|
|
| 702 |
/>
|
| 703 |
{selected.contact ? (
|
| 704 |
<p className="text-xs text-violet-700 mb-4">
|
|
@@ -719,6 +720,7 @@ export default function Leads() {
|
|
| 719 |
onFetch={fetchLeadCompanyAi}
|
| 720 |
fetchLoading={companyFetchLoading}
|
| 721 |
fetchError={companyFetchError}
|
|
|
|
| 722 |
/>
|
| 723 |
</div>
|
| 724 |
|
|
|
|
| 699 |
email={selected.email || ''}
|
| 700 |
title={selected.title || ''}
|
| 701 |
onSave={(patch) => patchLead(selected.id, patch)}
|
| 702 |
+
autoSave
|
| 703 |
/>
|
| 704 |
{selected.contact ? (
|
| 705 |
<p className="text-xs text-violet-700 mb-4">
|
|
|
|
| 720 |
onFetch={fetchLeadCompanyAi}
|
| 721 |
fetchLoading={companyFetchLoading}
|
| 722 |
fetchError={companyFetchError}
|
| 723 |
+
autoSave
|
| 724 |
/>
|
| 725 |
</div>
|
| 726 |
|