Seth commited on
Commit
ef62d3d
·
1 Parent(s): 03b24dc
backend/app/database.py CHANGED
@@ -166,6 +166,7 @@ class CrmDeal(Base):
166
  owner_user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
167
  owner_initials = Column(String, default="") # display cache; derived from User when owner_user_id set
168
  deal_value = Column(Integer, nullable=True) # whole USD (nullable)
 
169
  contact_display = Column(String, default="") # primary person label
170
  account_name = Column(String, default="")
171
  expected_close_date = Column(DateTime, nullable=True)
@@ -258,6 +259,15 @@ def run_migrations(connection_engine):
258
  dcols = [c["name"] for c in insp.get_columns("crm_deals")]
259
  if "owner_user_id" not in dcols:
260
  conn.execute(text("ALTER TABLE crm_deals ADD COLUMN owner_user_id INTEGER"))
 
 
 
 
 
 
 
 
 
261
 
262
 
263
  # Create tables then migrate legacy SQLite schemas
 
166
  owner_user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
167
  owner_initials = Column(String, default="") # display cache; derived from User when owner_user_id set
168
  deal_value = Column(Integer, nullable=True) # whole USD (nullable)
169
+ revenue_type = Column(String, default="arr") # arr|qrr|mrr|one_time
170
  contact_display = Column(String, default="") # primary person label
171
  account_name = Column(String, default="")
172
  expected_close_date = Column(DateTime, nullable=True)
 
259
  dcols = [c["name"] for c in insp.get_columns("crm_deals")]
260
  if "owner_user_id" not in dcols:
261
  conn.execute(text("ALTER TABLE crm_deals ADD COLUMN owner_user_id INTEGER"))
262
+ if "revenue_type" not in dcols:
263
+ conn.execute(
264
+ text("ALTER TABLE crm_deals ADD COLUMN revenue_type TEXT DEFAULT 'arr'")
265
+ )
266
+ conn.execute(
267
+ text(
268
+ "UPDATE crm_deals SET revenue_type = 'arr' WHERE revenue_type IS NULL OR revenue_type = ''"
269
+ )
270
+ )
271
 
272
 
273
  # Create tables then migrate legacy SQLite schemas
backend/app/main.py CHANGED
@@ -186,6 +186,7 @@ DEAL_STAGE_ALLOWED = frozenset({
186
  "won",
187
  "lost",
188
  })
 
189
  SMARTLEAD_IMPORT_FILE_ID = "smartlead-imports"
190
  MANUAL_CONTACT_FILE_ID = "manual-contacts"
191
  DEMO_CONTACTS_FILE_ID = "demo-contacts-crm"
@@ -547,6 +548,9 @@ def _deal_to_dict(d: CrmDeal) -> dict:
547
  ecd = d.expected_close_date.date().isoformat()
548
  except Exception:
549
  ecd = d.expected_close_date.isoformat()
 
 
 
550
  return {
551
  "id": d.id,
552
  "name": d.name or "",
@@ -554,6 +558,7 @@ def _deal_to_dict(d: CrmDeal) -> dict:
554
  "owner_user_id": d.owner_user_id,
555
  "owner_initials": d.owner_initials or "",
556
  "deal_value": d.deal_value,
 
557
  "contact_display": d.contact_display or "",
558
  "account_name": d.account_name or "",
559
  "expected_close_date": ecd,
@@ -2527,6 +2532,7 @@ async def deals_from_leads(body: BulkLeadIdsRequest, t: TenantContext = Depends(
2527
  owner_user_id=t.user_id,
2528
  owner_initials=_user_initials_from_user(inviter),
2529
  deal_value=None,
 
2530
  contact_display=person or (lead.email or ""),
2531
  account_name=lead.company_name or "",
2532
  expected_close_date=datetime.utcnow() + timedelta(days=60),
@@ -2626,6 +2632,7 @@ async def create_deal(body: CrmDealCreateRequest, t: TenantContext = Depends(get
2626
  owner_user_id=t.user_id,
2627
  owner_initials=_user_initials_from_user(creator),
2628
  deal_value=None,
 
2629
  contact_display="",
2630
  account_name="",
2631
  expected_close_date=None,
@@ -2743,6 +2750,14 @@ async def patch_deal(deal_id: int, body: CrmDealPatchRequest, t: TenantContext =
2743
  row.stage = data["stage"]
2744
  if "deal_value" in data:
2745
  row.deal_value = data["deal_value"]
 
 
 
 
 
 
 
 
2746
  if "close_probability" in data:
2747
  row.close_probability = max(0, min(100, int(data["close_probability"])))
2748
  if "country" in data:
 
186
  "won",
187
  "lost",
188
  })
189
+ DEAL_REVENUE_TYPE_ALLOWED = frozenset({"arr", "qrr", "mrr", "one_time"})
190
  SMARTLEAD_IMPORT_FILE_ID = "smartlead-imports"
191
  MANUAL_CONTACT_FILE_ID = "manual-contacts"
192
  DEMO_CONTACTS_FILE_ID = "demo-contacts-crm"
 
548
  ecd = d.expected_close_date.date().isoformat()
549
  except Exception:
550
  ecd = d.expected_close_date.isoformat()
551
+ rt = (getattr(d, "revenue_type", None) or "arr").strip().lower()
552
+ if rt not in DEAL_REVENUE_TYPE_ALLOWED:
553
+ rt = "arr"
554
  return {
555
  "id": d.id,
556
  "name": d.name or "",
 
558
  "owner_user_id": d.owner_user_id,
559
  "owner_initials": d.owner_initials or "",
560
  "deal_value": d.deal_value,
561
+ "revenue_type": rt,
562
  "contact_display": d.contact_display or "",
563
  "account_name": d.account_name or "",
564
  "expected_close_date": ecd,
 
2532
  owner_user_id=t.user_id,
2533
  owner_initials=_user_initials_from_user(inviter),
2534
  deal_value=None,
2535
+ revenue_type="arr",
2536
  contact_display=person or (lead.email or ""),
2537
  account_name=lead.company_name or "",
2538
  expected_close_date=datetime.utcnow() + timedelta(days=60),
 
2632
  owner_user_id=t.user_id,
2633
  owner_initials=_user_initials_from_user(creator),
2634
  deal_value=None,
2635
+ revenue_type="arr",
2636
  contact_display="",
2637
  account_name="",
2638
  expected_close_date=None,
 
2750
  row.stage = data["stage"]
2751
  if "deal_value" in data:
2752
  row.deal_value = data["deal_value"]
2753
+ if "revenue_type" in data:
2754
+ rt = _safe_str(data["revenue_type"]).lower()
2755
+ if rt not in DEAL_REVENUE_TYPE_ALLOWED:
2756
+ raise HTTPException(
2757
+ status_code=400,
2758
+ detail=f"revenue_type must be one of: {sorted(DEAL_REVENUE_TYPE_ALLOWED)}",
2759
+ )
2760
+ row.revenue_type = rt
2761
  if "close_probability" in data:
2762
  row.close_probability = max(0, min(100, int(data["close_probability"])))
2763
  if "country" in data:
backend/app/models.py CHANGED
@@ -102,6 +102,7 @@ class CrmDealPatchRequest(BaseModel):
102
  # Admin: set to a workspace member's user id, or null for unassigned. Member: may set to own id only to claim an unassigned deal.
103
  owner_user_id: Optional[int] = None
104
  deal_value: Optional[int] = None
 
105
  close_probability: Optional[int] = None
106
  expected_close_date: Optional[str] = None # ISO date or datetime
107
  country: Optional[str] = None
 
102
  # Admin: set to a workspace member's user id, or null for unassigned. Member: may set to own id only to claim an unassigned deal.
103
  owner_user_id: Optional[int] = None
104
  deal_value: Optional[int] = None
105
+ revenue_type: Optional[str] = None # arr|qrr|mrr|one_time
106
  close_probability: Optional[int] = None
107
  expected_close_date: Optional[str] = None # ISO date or datetime
108
  country: Optional[str] = None
frontend/src/pages/Deals.jsx CHANGED
@@ -101,6 +101,18 @@ function closeProbabilitySelectValue(p) {
101
  return String(Math.min(100, Math.max(10, Math.round(n / 10) * 10)));
102
  }
103
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  function focusFirstEditableInRow(tr) {
105
  if (!tr) return;
106
  const el = tr.querySelector('input:not([type="checkbox"]):not([type="hidden"]), textarea');
@@ -154,7 +166,7 @@ function GroupedDealTbody({
154
  return (
155
  <tbody className="border-b border-slate-200">
156
  <tr className={barClassName}>
157
- <td colSpan={12} className="px-3 py-2.5">
158
  {headerContent}
159
  </td>
160
  </tr>
@@ -177,12 +189,16 @@ function GroupedDealTbody({
177
  Group total
178
  </td>
179
  <td className="px-3 py-2 font-semibold tabular-nums">{fmtMoney(sumDeal)}</td>
180
- <td colSpan={4} />
 
 
 
 
181
  <td className="px-3 py-2 font-semibold tabular-nums">{fmtMoney(sumForecast)}</td>
182
  <td colSpan={2} />
183
  </tr>
184
  <tr>
185
- <td colSpan={12} className="px-3 py-1.5">
186
  <Button
187
  type="button"
188
  variant="ghost"
@@ -583,6 +599,30 @@ function DealRow({
583
  inputClassName="min-w-[9rem] w-full max-w-full tabular-nums text-right text-sm py-1"
584
  />
585
  </td>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
586
  <td className="px-3 py-2 align-top max-w-[180px]">
587
  {tableEditRowId === deal.id ? (
588
  <EditableCell
@@ -980,6 +1020,7 @@ export default function Deals() {
980
  setDealPanelForm({
981
  name: dealDetail.name || '',
982
  deal_value: dealDetail.deal_value != null ? String(dealDetail.deal_value) : '',
 
983
  close_prob: closeProbabilitySelectValue(dealDetail.close_probability),
984
  expected_close: isoToDateInput(dealDetail.expected_close_date),
985
  contact_display: dealDetail.contact_display || '',
@@ -1004,6 +1045,7 @@ export default function Deals() {
1004
  await patchDeal(dealDetail.id, {
1005
  name: dealPanelForm.name.trim() || 'Untitled deal',
1006
  deal_value,
 
1007
  close_probability,
1008
  expected_close_date: dealPanelForm.expected_close.trim() ? dealPanelForm.expected_close.trim() : null,
1009
  contact_display: dealPanelForm.contact_display.trim(),
@@ -1116,7 +1158,7 @@ export default function Deals() {
1116
  </div>
1117
  </div>
1118
  ) : (
1119
- <table className="w-full text-sm min-w-[960px]">
1120
  <thead>
1121
  <tr className="bg-slate-50 text-left text-slate-600 border-b border-slate-200">
1122
  <th className="px-2 py-2 w-[4.25rem]" aria-label="Select and table edit" />
@@ -1124,6 +1166,7 @@ export default function Deals() {
1124
  <th className="px-3 py-2 font-medium">Stage</th>
1125
  <th className="px-3 py-2 font-medium">Owner</th>
1126
  <th className="px-3 py-2 font-medium">Deal value</th>
 
1127
  <th className="px-3 py-2 font-medium">Contacts</th>
1128
  <th className="px-3 py-2 font-medium">Accounts</th>
1129
  <th className="px-3 py-2 font-medium">Expected close</th>
@@ -1304,6 +1347,34 @@ export default function Deals() {
1304
  placeholder="—"
1305
  />
1306
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1307
  <div>
1308
  <label className="block text-xs font-medium text-slate-500 mb-1">
1309
  Close probability
 
101
  return String(Math.min(100, Math.max(10, Math.round(n / 10) * 10)));
102
  }
103
 
104
+ const REVENUE_TYPES = [
105
+ { value: 'arr', label: 'ARR' },
106
+ { value: 'qrr', label: 'QRR' },
107
+ { value: 'mrr', label: 'MRR' },
108
+ { value: 'one_time', label: 'One-Time' },
109
+ ];
110
+
111
+ function revenueTypeSelectValue(deal) {
112
+ const v = (deal?.revenue_type ?? 'arr').toString().toLowerCase();
113
+ return REVENUE_TYPES.some((t) => t.value === v) ? v : 'arr';
114
+ }
115
+
116
  function focusFirstEditableInRow(tr) {
117
  if (!tr) return;
118
  const el = tr.querySelector('input:not([type="checkbox"]):not([type="hidden"]), textarea');
 
166
  return (
167
  <tbody className="border-b border-slate-200">
168
  <tr className={barClassName}>
169
+ <td colSpan={13} className="px-3 py-2.5">
170
  {headerContent}
171
  </td>
172
  </tr>
 
189
  Group total
190
  </td>
191
  <td className="px-3 py-2 font-semibold tabular-nums">{fmtMoney(sumDeal)}</td>
192
+ <td className="px-3 py-2 text-center text-slate-400" aria-hidden>
193
+
194
+ </td>
195
+ <td colSpan={3} />
196
+ <td className="px-3 py-2" />
197
  <td className="px-3 py-2 font-semibold tabular-nums">{fmtMoney(sumForecast)}</td>
198
  <td colSpan={2} />
199
  </tr>
200
  <tr>
201
+ <td colSpan={13} className="px-3 py-1.5">
202
  <Button
203
  type="button"
204
  variant="ghost"
 
599
  inputClassName="min-w-[9rem] w-full max-w-full tabular-nums text-right text-sm py-1"
600
  />
601
  </td>
602
+ <td className="px-3 py-2 align-top max-w-[7.5rem]" onClick={(e) => e.stopPropagation()}>
603
+ <Select
604
+ value={revenueTypeSelectValue(deal)}
605
+ onValueChange={(v) => patchDeal(deal.id, { revenue_type: v })}
606
+ >
607
+ <SelectTrigger
608
+ className={cn(
609
+ 'h-8 w-[min(100%,7.25rem)] border-slate-200 shadow-none text-xs',
610
+ '[&>svg]:h-3.5 [&>svg]:w-3.5 [&>svg]:opacity-60'
611
+ )}
612
+ >
613
+ <span className="truncate font-medium tabular-nums">
614
+ {REVENUE_TYPES.find((t) => t.value === revenueTypeSelectValue(deal))?.label}
615
+ </span>
616
+ </SelectTrigger>
617
+ <SelectContent className="min-w-[8.5rem]">
618
+ {REVENUE_TYPES.map((t) => (
619
+ <SelectItem key={t.value} value={t.value}>
620
+ {t.label}
621
+ </SelectItem>
622
+ ))}
623
+ </SelectContent>
624
+ </Select>
625
+ </td>
626
  <td className="px-3 py-2 align-top max-w-[180px]">
627
  {tableEditRowId === deal.id ? (
628
  <EditableCell
 
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 || '',
 
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(),
 
1158
  </div>
1159
  </div>
1160
  ) : (
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-[4.25rem]" aria-label="Select and table edit" />
 
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-3 py-2 font-medium">Deal value</th>
1169
+ <th className="px-3 py-2 font-medium">Revenue</th>
1170
  <th className="px-3 py-2 font-medium">Contacts</th>
1171
  <th className="px-3 py-2 font-medium">Accounts</th>
1172
  <th className="px-3 py-2 font-medium">Expected close</th>
 
1347
  placeholder="—"
1348
  />
1349
  </div>
1350
+ <div>
1351
+ <label className="block text-xs font-medium text-slate-500 mb-1">
1352
+ Revenue
1353
+ </label>
1354
+ <Select
1355
+ value={dealPanelForm.revenue_type}
1356
+ onValueChange={(v) =>
1357
+ setDealPanelForm((f) => ({ ...f, revenue_type: v }))
1358
+ }
1359
+ >
1360
+ <SelectTrigger className="h-10 border-slate-200 bg-white shadow-sm text-sm">
1361
+ <span className="font-medium">
1362
+ {
1363
+ REVENUE_TYPES.find(
1364
+ (t) => t.value === dealPanelForm.revenue_type
1365
+ )?.label
1366
+ }
1367
+ </span>
1368
+ </SelectTrigger>
1369
+ <SelectContent>
1370
+ {REVENUE_TYPES.map((t) => (
1371
+ <SelectItem key={t.value} value={t.value}>
1372
+ {t.label}
1373
+ </SelectItem>
1374
+ ))}
1375
+ </SelectContent>
1376
+ </Select>
1377
+ </div>
1378
  <div>
1379
  <label className="block text-xs font-medium text-slate-500 mb-1">
1380
  Close probability