Seth commited on
Commit ·
4399645
1
Parent(s): fe92a01
update
Browse files
backend/app/main.py
CHANGED
|
@@ -39,6 +39,7 @@ from .models import (
|
|
| 39 |
BulkLeadIdsRequest,
|
| 40 |
BulkContactIdsRequest,
|
| 41 |
CrmDealPatchRequest,
|
|
|
|
| 42 |
ContactCreateRequest,
|
| 43 |
ContactPatchRequest,
|
| 44 |
)
|
|
@@ -1927,6 +1928,37 @@ async def list_deals(
|
|
| 1927 |
return {"total": total, "deals": [_deal_to_dict(r) for r in rows]}
|
| 1928 |
|
| 1929 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1930 |
@app.get("/api/deals/{deal_id}")
|
| 1931 |
async def get_deal(deal_id: int, db: Session = Depends(get_db)):
|
| 1932 |
row = db.query(CrmDeal).filter(CrmDeal.id == deal_id).first()
|
|
|
|
| 39 |
BulkLeadIdsRequest,
|
| 40 |
BulkContactIdsRequest,
|
| 41 |
CrmDealPatchRequest,
|
| 42 |
+
CrmDealCreateRequest,
|
| 43 |
ContactCreateRequest,
|
| 44 |
ContactPatchRequest,
|
| 45 |
)
|
|
|
|
| 1928 |
return {"total": total, "deals": [_deal_to_dict(r) for r in rows]}
|
| 1929 |
|
| 1930 |
|
| 1931 |
+
@app.post("/api/deals")
|
| 1932 |
+
async def create_deal(body: CrmDealCreateRequest, db: Session = Depends(get_db)):
|
| 1933 |
+
stage = (body.stage or "new").strip().lower() or "new"
|
| 1934 |
+
if stage not in DEAL_STAGE_ALLOWED:
|
| 1935 |
+
raise HTTPException(
|
| 1936 |
+
status_code=400,
|
| 1937 |
+
detail=f"stage must be one of: {sorted(DEAL_STAGE_ALLOWED)}",
|
| 1938 |
+
)
|
| 1939 |
+
raw_name = _safe_str(body.name) if body.name is not None else ""
|
| 1940 |
+
name = raw_name or "Untitled deal"
|
| 1941 |
+
row = CrmDeal(
|
| 1942 |
+
name=name,
|
| 1943 |
+
stage=stage,
|
| 1944 |
+
owner_initials="",
|
| 1945 |
+
deal_value=None,
|
| 1946 |
+
contact_display="",
|
| 1947 |
+
account_name="",
|
| 1948 |
+
expected_close_date=None,
|
| 1949 |
+
close_probability=10,
|
| 1950 |
+
country="",
|
| 1951 |
+
last_interaction_at=None,
|
| 1952 |
+
source_lead_id=None,
|
| 1953 |
+
source_campaign_name="",
|
| 1954 |
+
contact_id=None,
|
| 1955 |
+
)
|
| 1956 |
+
db.add(row)
|
| 1957 |
+
db.commit()
|
| 1958 |
+
db.refresh(row)
|
| 1959 |
+
return _deal_to_dict(row)
|
| 1960 |
+
|
| 1961 |
+
|
| 1962 |
@app.get("/api/deals/{deal_id}")
|
| 1963 |
async def get_deal(deal_id: int, db: Session = Depends(get_db)):
|
| 1964 |
row = db.query(CrmDeal).filter(CrmDeal.id == deal_id).first()
|
backend/app/models.py
CHANGED
|
@@ -68,6 +68,13 @@ class BulkContactIdsRequest(BaseModel):
|
|
| 68 |
contact_ids: List[int]
|
| 69 |
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
class CrmDealPatchRequest(BaseModel):
|
| 72 |
name: Optional[str] = None
|
| 73 |
stage: Optional[str] = None
|
|
|
|
| 68 |
contact_ids: List[int]
|
| 69 |
|
| 70 |
|
| 71 |
+
class CrmDealCreateRequest(BaseModel):
|
| 72 |
+
"""Create a blank pipeline deal (optionally in a given stage)."""
|
| 73 |
+
|
| 74 |
+
name: Optional[str] = None
|
| 75 |
+
stage: Optional[str] = "new"
|
| 76 |
+
|
| 77 |
+
|
| 78 |
class CrmDealPatchRequest(BaseModel):
|
| 79 |
name: Optional[str] = None
|
| 80 |
stage: Optional[str] = None
|
frontend/src/components/workspace/MainTableWorkspace.jsx
CHANGED
|
@@ -64,6 +64,7 @@ export default function MainTableWorkspace({
|
|
| 64 |
<Button
|
| 65 |
size="sm"
|
| 66 |
type="button"
|
|
|
|
| 67 |
className="bg-teal-600 hover:bg-teal-700 text-white shadow-sm inline-flex items-center gap-1.5"
|
| 68 |
onClick={primaryAction.onClick}
|
| 69 |
>
|
|
|
|
| 64 |
<Button
|
| 65 |
size="sm"
|
| 66 |
type="button"
|
| 67 |
+
disabled={primaryAction.disabled}
|
| 68 |
className="bg-teal-600 hover:bg-teal-700 text-white shadow-sm inline-flex items-center gap-1.5"
|
| 69 |
onClick={primaryAction.onClick}
|
| 70 |
>
|
frontend/src/pages/Deals.jsx
CHANGED
|
@@ -83,6 +83,7 @@ function DealRow({ deal, tableEditRowId, setTableEditRowId, patchDeal, updateSta
|
|
| 83 |
const closeSel = closeProbabilitySelectValue(deal.close_probability);
|
| 84 |
return (
|
| 85 |
<tr
|
|
|
|
| 86 |
role="button"
|
| 87 |
tabIndex={0}
|
| 88 |
onClick={(e) => {
|
|
@@ -321,6 +322,7 @@ export default function Deals() {
|
|
| 321 |
const [dealDetail, setDealDetail] = useState(null);
|
| 322 |
const [tableEditRowId, setTableEditRowId] = useState(null);
|
| 323 |
const [dealsView, setDealsView] = useState('main');
|
|
|
|
| 324 |
|
| 325 |
const fetchDeals = useCallback(async () => {
|
| 326 |
setLoading(true);
|
|
@@ -377,6 +379,44 @@ export default function Deals() {
|
|
| 377 |
}
|
| 378 |
};
|
| 379 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 380 |
const patchDeal = async (dealId, patch) => {
|
| 381 |
try {
|
| 382 |
const res = await fetch(`/api/deals/${dealId}`, {
|
|
@@ -399,8 +439,8 @@ export default function Deals() {
|
|
| 399 |
|
| 400 |
const updateStage = (dealId, stage) => patchDeal(dealId, { stage });
|
| 401 |
|
| 402 |
-
const openDeal = async (deal) => {
|
| 403 |
-
setTableEditRowId(null);
|
| 404 |
setPanelOpen(true);
|
| 405 |
setDealDetail(deal);
|
| 406 |
try {
|
|
@@ -439,7 +479,11 @@ export default function Deals() {
|
|
| 439 |
},
|
| 440 |
{ label: 'Pipeline', active: false, disabled: true },
|
| 441 |
]}
|
| 442 |
-
primaryAction={{
|
|
|
|
|
|
|
|
|
|
|
|
|
| 443 |
search={{
|
| 444 |
value: search,
|
| 445 |
onChange: (e) => setSearch(e.target.value),
|
|
@@ -468,10 +512,24 @@ export default function Deals() {
|
|
| 468 |
</div>
|
| 469 |
) : deals.length === 0 ? (
|
| 470 |
<div className="text-center py-16 text-slate-500 space-y-3">
|
| 471 |
-
<p>No deals yet. Convert leads from the Leads page,
|
| 472 |
-
<
|
| 473 |
-
|
| 474 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 475 |
</div>
|
| 476 |
) : (
|
| 477 |
<table className="w-full text-sm min-w-[960px]">
|
|
@@ -563,7 +621,8 @@ export default function Deals() {
|
|
| 563 |
variant="ghost"
|
| 564 |
size="sm"
|
| 565 |
className="h-8 text-violet-700 hover:text-violet-900 hover:bg-violet-50"
|
| 566 |
-
|
|
|
|
| 567 |
>
|
| 568 |
+ Add deal
|
| 569 |
</Button>
|
|
|
|
| 83 |
const closeSel = closeProbabilitySelectValue(deal.close_probability);
|
| 84 |
return (
|
| 85 |
<tr
|
| 86 |
+
data-deal-id={deal.id}
|
| 87 |
role="button"
|
| 88 |
tabIndex={0}
|
| 89 |
onClick={(e) => {
|
|
|
|
| 322 |
const [dealDetail, setDealDetail] = useState(null);
|
| 323 |
const [tableEditRowId, setTableEditRowId] = useState(null);
|
| 324 |
const [dealsView, setDealsView] = useState('main');
|
| 325 |
+
const [createBusy, setCreateBusy] = useState(false);
|
| 326 |
|
| 327 |
const fetchDeals = useCallback(async () => {
|
| 328 |
setLoading(true);
|
|
|
|
| 379 |
}
|
| 380 |
};
|
| 381 |
|
| 382 |
+
const createDeal = async (stage = 'new') => {
|
| 383 |
+
if (createBusy) return;
|
| 384 |
+
const st = STAGES.some((s) => s.value === stage) ? stage : 'new';
|
| 385 |
+
setCreateBusy(true);
|
| 386 |
+
try {
|
| 387 |
+
const res = await fetch('/api/deals', {
|
| 388 |
+
method: 'POST',
|
| 389 |
+
headers: { 'Content-Type': 'application/json' },
|
| 390 |
+
body: JSON.stringify({ name: 'Untitled deal', stage: st }),
|
| 391 |
+
});
|
| 392 |
+
const data = await res.json().catch(() => ({}));
|
| 393 |
+
if (!res.ok) {
|
| 394 |
+
const d = data.detail;
|
| 395 |
+
const msg =
|
| 396 |
+
typeof d === 'string'
|
| 397 |
+
? d
|
| 398 |
+
: Array.isArray(d)
|
| 399 |
+
? d.map((x) => (typeof x === 'string' ? x : x.msg || '')).filter(Boolean).join(', ')
|
| 400 |
+
: 'Could not create deal';
|
| 401 |
+
throw new Error(msg || 'Could not create deal');
|
| 402 |
+
}
|
| 403 |
+
await fetchDeals();
|
| 404 |
+
setTableEditRowId(data.id);
|
| 405 |
+
openDeal(data, { keepTableEdit: true });
|
| 406 |
+
requestAnimationFrame(() => {
|
| 407 |
+
requestAnimationFrame(() => {
|
| 408 |
+
const tr = document.querySelector(`tr[data-deal-id="${data.id}"]`);
|
| 409 |
+
focusFirstEditableInRow(tr);
|
| 410 |
+
});
|
| 411 |
+
});
|
| 412 |
+
} catch (e) {
|
| 413 |
+
console.error(e);
|
| 414 |
+
alert(e.message || 'Could not create deal');
|
| 415 |
+
} finally {
|
| 416 |
+
setCreateBusy(false);
|
| 417 |
+
}
|
| 418 |
+
};
|
| 419 |
+
|
| 420 |
const patchDeal = async (dealId, patch) => {
|
| 421 |
try {
|
| 422 |
const res = await fetch(`/api/deals/${dealId}`, {
|
|
|
|
| 439 |
|
| 440 |
const updateStage = (dealId, stage) => patchDeal(dealId, { stage });
|
| 441 |
|
| 442 |
+
const openDeal = async (deal, opts = {}) => {
|
| 443 |
+
if (!opts.keepTableEdit) setTableEditRowId(null);
|
| 444 |
setPanelOpen(true);
|
| 445 |
setDealDetail(deal);
|
| 446 |
try {
|
|
|
|
| 479 |
},
|
| 480 |
{ label: 'Pipeline', active: false, disabled: true },
|
| 481 |
]}
|
| 482 |
+
primaryAction={{
|
| 483 |
+
label: 'New deal',
|
| 484 |
+
onClick: () => createDeal('new'),
|
| 485 |
+
disabled: createBusy,
|
| 486 |
+
}}
|
| 487 |
search={{
|
| 488 |
value: search,
|
| 489 |
onChange: (e) => setSearch(e.target.value),
|
|
|
|
| 512 |
</div>
|
| 513 |
) : deals.length === 0 ? (
|
| 514 |
<div className="text-center py-16 text-slate-500 space-y-3">
|
| 515 |
+
<p>No deals yet. Convert leads from the Leads page, load demo data, or create a deal.</p>
|
| 516 |
+
<div className="flex flex-wrap items-center justify-center gap-2">
|
| 517 |
+
<Button
|
| 518 |
+
size="sm"
|
| 519 |
+
className="bg-teal-600 hover:bg-teal-700 text-white"
|
| 520 |
+
onClick={() => createDeal('new')}
|
| 521 |
+
disabled={createBusy}
|
| 522 |
+
>
|
| 523 |
+
{createBusy ? (
|
| 524 |
+
<Loader2 className="h-4 w-4 animate-spin" />
|
| 525 |
+
) : (
|
| 526 |
+
'New deal'
|
| 527 |
+
)}
|
| 528 |
+
</Button>
|
| 529 |
+
<Button size="sm" variant="secondary" onClick={() => seedDemo()} disabled={seedBusy}>
|
| 530 |
+
Load demo deals
|
| 531 |
+
</Button>
|
| 532 |
+
</div>
|
| 533 |
</div>
|
| 534 |
) : (
|
| 535 |
<table className="w-full text-sm min-w-[960px]">
|
|
|
|
| 621 |
variant="ghost"
|
| 622 |
size="sm"
|
| 623 |
className="h-8 text-violet-700 hover:text-violet-900 hover:bg-violet-50"
|
| 624 |
+
disabled={createBusy}
|
| 625 |
+
onClick={() => createDeal(group.value)}
|
| 626 |
>
|
| 627 |
+ Add deal
|
| 628 |
</Button>
|