Seth commited on
Commit ·
0ac8264
1
Parent(s): 2a3a36f
update
Browse files
frontend/src/components/workspace/SlideOverPanel.jsx
CHANGED
|
@@ -41,7 +41,11 @@ export default function SlideOverPanel({
|
|
| 41 |
>
|
| 42 |
<div className="flex justify-between items-start gap-4 mb-6">
|
| 43 |
<div className="min-w-0 pr-2 flex-1">
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
{subtitle ? (
|
| 46 |
<p className="text-sm text-slate-500 mt-1 break-words">{subtitle}</p>
|
| 47 |
) : null}
|
|
|
|
| 41 |
>
|
| 42 |
<div className="flex justify-between items-start gap-4 mb-6">
|
| 43 |
<div className="min-w-0 pr-2 flex-1">
|
| 44 |
+
{typeof title === 'string' || title == null ? (
|
| 45 |
+
<h3 className="text-lg font-bold text-slate-900 truncate">{title || ''}</h3>
|
| 46 |
+
) : (
|
| 47 |
+
<div className="w-full min-w-0">{title}</div>
|
| 48 |
+
)}
|
| 49 |
{subtitle ? (
|
| 50 |
<p className="text-sm text-slate-500 mt-1 break-words">{subtitle}</p>
|
| 51 |
) : null}
|
frontend/src/pages/Deals.jsx
CHANGED
|
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
| 2 |
import { Link } from 'react-router-dom';
|
| 3 |
import { GitBranch, Loader2, LayoutGrid, MessageSquare, Pencil } 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 MainTableWorkspace from '@/components/workspace/MainTableWorkspace';
|
|
@@ -82,6 +83,13 @@ function isRowUiTarget(e) {
|
|
| 82 |
);
|
| 83 |
}
|
| 84 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
/** Maps stored probability to Select value: __clear__ or "10"…"100" (nearest 10, min 10). */
|
| 86 |
function closeProbabilitySelectValue(p) {
|
| 87 |
if (p == null || p === '') return '__clear__';
|
|
@@ -591,6 +599,8 @@ export default function Deals() {
|
|
| 591 |
const [createBusy, setCreateBusy] = useState(false);
|
| 592 |
const [companyFetchLoading, setCompanyFetchLoading] = useState(false);
|
| 593 |
const [companyFetchError, setCompanyFetchError] = useState('');
|
|
|
|
|
|
|
| 594 |
|
| 595 |
const fetchDeals = useCallback(async () => {
|
| 596 |
setLoading(true);
|
|
@@ -812,6 +822,7 @@ export default function Deals() {
|
|
| 812 |
if (!opts.keepTableEdit) setTableEditRowId(null);
|
| 813 |
setPanelOpen(true);
|
| 814 |
setDealDetail(deal);
|
|
|
|
| 815 |
try {
|
| 816 |
const res = await fetch(`/api/deals/${deal.id}`);
|
| 817 |
if (res.ok) {
|
|
@@ -823,6 +834,50 @@ export default function Deals() {
|
|
| 823 |
}
|
| 824 |
};
|
| 825 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 826 |
const closePanel = () => {
|
| 827 |
setTableEditRowId(null);
|
| 828 |
setPanelOpen(false);
|
|
@@ -1053,7 +1108,21 @@ export default function Deals() {
|
|
| 1053 |
<SlideOverPanel
|
| 1054 |
open={panelOpen && !!dealDetail}
|
| 1055 |
onClose={closePanel}
|
| 1056 |
-
title={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1057 |
subtitle={
|
| 1058 |
dealDetail?.stage
|
| 1059 |
? `Stage: ${stageMeta(dealDetail.stage).label} · Forecast ${fmtMoney(dealDetail.forecast_value)}`
|
|
@@ -1061,9 +1130,132 @@ export default function Deals() {
|
|
| 1061 |
}
|
| 1062 |
widthClassName="max-w-xl"
|
| 1063 |
>
|
| 1064 |
-
{dealDetail && (
|
| 1065 |
<div className="space-y-6 text-sm">
|
| 1066 |
-
<section>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1067 |
<h4 className="text-sm font-semibold text-slate-800 mb-2">Contact details</h4>
|
| 1068 |
<DealContactSearch
|
| 1069 |
linkedContactId={dealDetail.contact_id}
|
|
@@ -1094,35 +1286,6 @@ export default function Deals() {
|
|
| 1094 |
)}
|
| 1095 |
</section>
|
| 1096 |
|
| 1097 |
-
<dl className="space-y-3 border-t border-slate-100 pt-6">
|
| 1098 |
-
<div>
|
| 1099 |
-
<dt className="text-slate-500">Deal value</dt>
|
| 1100 |
-
<dd className="font-medium tabular-nums">{fmtMoney(dealDetail.deal_value)}</dd>
|
| 1101 |
-
</div>
|
| 1102 |
-
<div>
|
| 1103 |
-
<dt className="text-slate-500">Close probability</dt>
|
| 1104 |
-
<dd>{dealDetail.close_probability ?? '—'}%</dd>
|
| 1105 |
-
</div>
|
| 1106 |
-
<div>
|
| 1107 |
-
<dt className="text-slate-500">Expected close</dt>
|
| 1108 |
-
<dd>{fmtDate(dealDetail.expected_close_date)}</dd>
|
| 1109 |
-
</div>
|
| 1110 |
-
<div>
|
| 1111 |
-
<dt className="text-slate-500">Contact (display)</dt>
|
| 1112 |
-
<dd>{dealDetail.contact_display || '—'}</dd>
|
| 1113 |
-
</div>
|
| 1114 |
-
<div>
|
| 1115 |
-
<dt className="text-slate-500">Last interaction</dt>
|
| 1116 |
-
<dd>{fmtDate(dealDetail.last_interaction_at)}</dd>
|
| 1117 |
-
</div>
|
| 1118 |
-
{dealDetail.source_campaign_name ? (
|
| 1119 |
-
<div>
|
| 1120 |
-
<dt className="text-slate-500">Source campaign</dt>
|
| 1121 |
-
<dd>{dealDetail.source_campaign_name}</dd>
|
| 1122 |
-
</div>
|
| 1123 |
-
) : null}
|
| 1124 |
-
</dl>
|
| 1125 |
-
|
| 1126 |
<section className="border-t border-slate-100 pt-6 mt-2">
|
| 1127 |
<h4 className="text-sm font-semibold text-slate-800 mb-2">Company details</h4>
|
| 1128 |
<DealCompanySearch onPatchDeal={(patch) => patchDeal(dealDetail.id, patch)} />
|
|
|
|
| 2 |
import { Link } from 'react-router-dom';
|
| 3 |
import { GitBranch, Loader2, LayoutGrid, MessageSquare, Pencil } from 'lucide-react';
|
| 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 MainTableWorkspace from '@/components/workspace/MainTableWorkspace';
|
|
|
|
| 83 |
);
|
| 84 |
}
|
| 85 |
|
| 86 |
+
function isoToDateInput(iso) {
|
| 87 |
+
if (!iso) return '';
|
| 88 |
+
const d = new Date(iso);
|
| 89 |
+
if (Number.isNaN(d.getTime())) return '';
|
| 90 |
+
return d.toISOString().slice(0, 10);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
/** Maps stored probability to Select value: __clear__ or "10"…"100" (nearest 10, min 10). */
|
| 94 |
function closeProbabilitySelectValue(p) {
|
| 95 |
if (p == null || p === '') return '__clear__';
|
|
|
|
| 599 |
const [createBusy, setCreateBusy] = useState(false);
|
| 600 |
const [companyFetchLoading, setCompanyFetchLoading] = useState(false);
|
| 601 |
const [companyFetchError, setCompanyFetchError] = useState('');
|
| 602 |
+
const [dealPanelForm, setDealPanelForm] = useState(null);
|
| 603 |
+
const [dealPanelSaving, setDealPanelSaving] = useState(false);
|
| 604 |
|
| 605 |
const fetchDeals = useCallback(async () => {
|
| 606 |
setLoading(true);
|
|
|
|
| 822 |
if (!opts.keepTableEdit) setTableEditRowId(null);
|
| 823 |
setPanelOpen(true);
|
| 824 |
setDealDetail(deal);
|
| 825 |
+
setDealPanelForm(null);
|
| 826 |
try {
|
| 827 |
const res = await fetch(`/api/deals/${deal.id}`);
|
| 828 |
if (res.ok) {
|
|
|
|
| 834 |
}
|
| 835 |
};
|
| 836 |
|
| 837 |
+
useEffect(() => {
|
| 838 |
+
if (!dealDetail) {
|
| 839 |
+
setDealPanelForm(null);
|
| 840 |
+
return;
|
| 841 |
+
}
|
| 842 |
+
setDealPanelForm({
|
| 843 |
+
name: dealDetail.name || '',
|
| 844 |
+
deal_value: dealDetail.deal_value != null ? String(dealDetail.deal_value) : '',
|
| 845 |
+
close_prob: closeProbabilitySelectValue(dealDetail.close_probability),
|
| 846 |
+
expected_close: isoToDateInput(dealDetail.expected_close_date),
|
| 847 |
+
contact_display: dealDetail.contact_display || '',
|
| 848 |
+
last_interaction: isoToDateInput(dealDetail.last_interaction_at),
|
| 849 |
+
});
|
| 850 |
+
}, [dealDetail]);
|
| 851 |
+
|
| 852 |
+
const saveDealPanel = async () => {
|
| 853 |
+
if (!dealDetail || !dealPanelForm) return;
|
| 854 |
+
setDealPanelSaving(true);
|
| 855 |
+
try {
|
| 856 |
+
let deal_value = null;
|
| 857 |
+
if (dealPanelForm.deal_value.trim() !== '') {
|
| 858 |
+
const n = Math.round(Number(dealPanelForm.deal_value));
|
| 859 |
+
if (Number.isFinite(n)) deal_value = n;
|
| 860 |
+
}
|
| 861 |
+
let close_probability = 0;
|
| 862 |
+
if (dealPanelForm.close_prob !== '__clear__') {
|
| 863 |
+
close_probability = Number(dealPanelForm.close_prob);
|
| 864 |
+
if (!Number.isFinite(close_probability)) close_probability = 0;
|
| 865 |
+
}
|
| 866 |
+
await patchDeal(dealDetail.id, {
|
| 867 |
+
name: dealPanelForm.name.trim() || 'Untitled deal',
|
| 868 |
+
deal_value,
|
| 869 |
+
close_probability,
|
| 870 |
+
expected_close_date: dealPanelForm.expected_close.trim() ? dealPanelForm.expected_close.trim() : null,
|
| 871 |
+
contact_display: dealPanelForm.contact_display.trim(),
|
| 872 |
+
last_interaction_at: dealPanelForm.last_interaction.trim()
|
| 873 |
+
? dealPanelForm.last_interaction.trim()
|
| 874 |
+
: null,
|
| 875 |
+
});
|
| 876 |
+
} finally {
|
| 877 |
+
setDealPanelSaving(false);
|
| 878 |
+
}
|
| 879 |
+
};
|
| 880 |
+
|
| 881 |
const closePanel = () => {
|
| 882 |
setTableEditRowId(null);
|
| 883 |
setPanelOpen(false);
|
|
|
|
| 1108 |
<SlideOverPanel
|
| 1109 |
open={panelOpen && !!dealDetail}
|
| 1110 |
onClose={closePanel}
|
| 1111 |
+
title={
|
| 1112 |
+
dealPanelForm ? (
|
| 1113 |
+
<Input
|
| 1114 |
+
value={dealPanelForm.name}
|
| 1115 |
+
onChange={(e) =>
|
| 1116 |
+
setDealPanelForm((f) => ({ ...f, name: e.target.value }))
|
| 1117 |
+
}
|
| 1118 |
+
className="text-lg font-semibold text-slate-900 border-slate-200 h-10 px-3 w-full max-w-full shadow-sm"
|
| 1119 |
+
placeholder="Deal name"
|
| 1120 |
+
aria-label="Deal name"
|
| 1121 |
+
/>
|
| 1122 |
+
) : (
|
| 1123 |
+
dealDetail?.name || 'Deal'
|
| 1124 |
+
)
|
| 1125 |
+
}
|
| 1126 |
subtitle={
|
| 1127 |
dealDetail?.stage
|
| 1128 |
? `Stage: ${stageMeta(dealDetail.stage).label} · Forecast ${fmtMoney(dealDetail.forecast_value)}`
|
|
|
|
| 1130 |
}
|
| 1131 |
widthClassName="max-w-xl"
|
| 1132 |
>
|
| 1133 |
+
{dealDetail && dealPanelForm && (
|
| 1134 |
<div className="space-y-6 text-sm">
|
| 1135 |
+
<section className="rounded-xl border border-slate-200 bg-slate-50/50 p-4">
|
| 1136 |
+
<h4 className="text-sm font-semibold text-slate-800 mb-3">Deal information</h4>
|
| 1137 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-4">
|
| 1138 |
+
<div>
|
| 1139 |
+
<label className="block text-xs font-medium text-slate-500 mb-1">
|
| 1140 |
+
Deal value (USD)
|
| 1141 |
+
</label>
|
| 1142 |
+
<Input
|
| 1143 |
+
type="number"
|
| 1144 |
+
min={0}
|
| 1145 |
+
step={1}
|
| 1146 |
+
value={dealPanelForm.deal_value}
|
| 1147 |
+
onChange={(e) =>
|
| 1148 |
+
setDealPanelForm((f) => ({
|
| 1149 |
+
...f,
|
| 1150 |
+
deal_value: e.target.value,
|
| 1151 |
+
}))
|
| 1152 |
+
}
|
| 1153 |
+
className="text-sm tabular-nums bg-white"
|
| 1154 |
+
placeholder="—"
|
| 1155 |
+
/>
|
| 1156 |
+
</div>
|
| 1157 |
+
<div>
|
| 1158 |
+
<label className="block text-xs font-medium text-slate-500 mb-1">
|
| 1159 |
+
Close probability
|
| 1160 |
+
</label>
|
| 1161 |
+
<Select
|
| 1162 |
+
value={dealPanelForm.close_prob}
|
| 1163 |
+
onValueChange={(v) =>
|
| 1164 |
+
setDealPanelForm((f) => ({ ...f, close_prob: v }))
|
| 1165 |
+
}
|
| 1166 |
+
>
|
| 1167 |
+
<SelectTrigger className="h-10 border-slate-200 bg-white shadow-sm">
|
| 1168 |
+
<span className="tabular-nums">
|
| 1169 |
+
{dealPanelForm.close_prob === '__clear__'
|
| 1170 |
+
? '—'
|
| 1171 |
+
: `${dealPanelForm.close_prob}%`}
|
| 1172 |
+
</span>
|
| 1173 |
+
</SelectTrigger>
|
| 1174 |
+
<SelectContent>
|
| 1175 |
+
<SelectItem value="__clear__">
|
| 1176 |
+
<span className="text-slate-500">—</span>
|
| 1177 |
+
</SelectItem>
|
| 1178 |
+
{[10, 20, 30, 40, 50, 60, 70, 80, 90, 100].map((n) => (
|
| 1179 |
+
<SelectItem key={n} value={String(n)}>
|
| 1180 |
+
<span className="tabular-nums">{n}%</span>
|
| 1181 |
+
</SelectItem>
|
| 1182 |
+
))}
|
| 1183 |
+
</SelectContent>
|
| 1184 |
+
</Select>
|
| 1185 |
+
</div>
|
| 1186 |
+
<div>
|
| 1187 |
+
<label className="block text-xs font-medium text-slate-500 mb-1">
|
| 1188 |
+
Expected close
|
| 1189 |
+
</label>
|
| 1190 |
+
<Input
|
| 1191 |
+
type="date"
|
| 1192 |
+
value={dealPanelForm.expected_close}
|
| 1193 |
+
onChange={(e) =>
|
| 1194 |
+
setDealPanelForm((f) => ({
|
| 1195 |
+
...f,
|
| 1196 |
+
expected_close: e.target.value,
|
| 1197 |
+
}))
|
| 1198 |
+
}
|
| 1199 |
+
className="text-sm bg-white"
|
| 1200 |
+
/>
|
| 1201 |
+
</div>
|
| 1202 |
+
<div>
|
| 1203 |
+
<label className="block text-xs font-medium text-slate-500 mb-1">
|
| 1204 |
+
Last interaction
|
| 1205 |
+
</label>
|
| 1206 |
+
<Input
|
| 1207 |
+
type="date"
|
| 1208 |
+
value={dealPanelForm.last_interaction}
|
| 1209 |
+
onChange={(e) =>
|
| 1210 |
+
setDealPanelForm((f) => ({
|
| 1211 |
+
...f,
|
| 1212 |
+
last_interaction: e.target.value,
|
| 1213 |
+
}))
|
| 1214 |
+
}
|
| 1215 |
+
className="text-sm bg-white"
|
| 1216 |
+
/>
|
| 1217 |
+
</div>
|
| 1218 |
+
<div className="sm:col-span-2">
|
| 1219 |
+
<label className="block text-xs font-medium text-slate-500 mb-1">
|
| 1220 |
+
Contact (display)
|
| 1221 |
+
</label>
|
| 1222 |
+
<Input
|
| 1223 |
+
value={dealPanelForm.contact_display}
|
| 1224 |
+
onChange={(e) =>
|
| 1225 |
+
setDealPanelForm((f) => ({
|
| 1226 |
+
...f,
|
| 1227 |
+
contact_display: e.target.value,
|
| 1228 |
+
}))
|
| 1229 |
+
}
|
| 1230 |
+
className="text-sm bg-white"
|
| 1231 |
+
placeholder="Name shown on deal card"
|
| 1232 |
+
/>
|
| 1233 |
+
</div>
|
| 1234 |
+
</div>
|
| 1235 |
+
<Button
|
| 1236 |
+
type="button"
|
| 1237 |
+
className="w-full sm:w-auto"
|
| 1238 |
+
onClick={saveDealPanel}
|
| 1239 |
+
disabled={dealPanelSaving}
|
| 1240 |
+
>
|
| 1241 |
+
{dealPanelSaving ? (
|
| 1242 |
+
<>
|
| 1243 |
+
<Loader2 className="h-4 w-4 animate-spin mr-2" aria-hidden />
|
| 1244 |
+
Saving…
|
| 1245 |
+
</>
|
| 1246 |
+
) : (
|
| 1247 |
+
'Save deal'
|
| 1248 |
+
)}
|
| 1249 |
+
</Button>
|
| 1250 |
+
{dealDetail.source_campaign_name ? (
|
| 1251 |
+
<p className="mt-3 text-xs text-slate-500">
|
| 1252 |
+
Source campaign:{' '}
|
| 1253 |
+
<span className="text-slate-700">{dealDetail.source_campaign_name}</span>
|
| 1254 |
+
</p>
|
| 1255 |
+
) : null}
|
| 1256 |
+
</section>
|
| 1257 |
+
|
| 1258 |
+
<section className="border-t border-slate-100 pt-6">
|
| 1259 |
<h4 className="text-sm font-semibold text-slate-800 mb-2">Contact details</h4>
|
| 1260 |
<DealContactSearch
|
| 1261 |
linkedContactId={dealDetail.contact_id}
|
|
|
|
| 1286 |
)}
|
| 1287 |
</section>
|
| 1288 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1289 |
<section className="border-t border-slate-100 pt-6 mt-2">
|
| 1290 |
<h4 className="text-sm font-semibold text-slate-800 mb-2">Company details</h4>
|
| 1291 |
<DealCompanySearch onPatchDeal={(patch) => patchDeal(dealDetail.id, patch)} />
|