Seth commited on
Commit ·
ef62d3d
1
Parent(s): 03b24dc
update
Browse files- backend/app/database.py +10 -0
- backend/app/main.py +15 -0
- backend/app/models.py +1 -0
- frontend/src/pages/Deals.jsx +75 -4
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={
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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={
|
| 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-[
|
| 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
|