Seth commited on
Commit
0ac8264
·
1 Parent(s): 2a3a36f
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
- <h3 className="text-lg font-bold text-slate-900 truncate">{title}</h3>
 
 
 
 
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={dealDetail?.name || 'Deal'}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)} />