Seth commited on
Commit
4399645
·
1 Parent(s): fe92a01
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={{ label: 'New deal', onClick: () => {} }}
 
 
 
 
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, or load demo data.</p>
472
- <Button size="sm" variant="secondary" onClick={() => seedDemo()} disabled={seedBusy}>
473
- Load demo deals
474
- </Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- onClick={() => {}}
 
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>