Seth commited on
Commit
04c41a8
·
1 Parent(s): 5ff7dfd
backend/app/database.py CHANGED
@@ -163,7 +163,8 @@ class CrmDeal(Base):
163
  tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True, index=True)
164
  name = Column(String, index=True)
165
  stage = Column(String, default="new") # new|discovery|proposal|negotiation|won|lost
166
- owner_initials = Column(String, default="")
 
167
  deal_value = Column(Integer, nullable=True) # whole USD (nullable)
168
  contact_display = Column(String, default="") # primary person label
169
  account_name = Column(String, default="")
@@ -252,6 +253,12 @@ def run_migrations(connection_engine):
252
  if "google_refresh_token" not in ucols:
253
  conn.execute(text("ALTER TABLE users ADD COLUMN google_refresh_token TEXT"))
254
 
 
 
 
 
 
 
255
 
256
  # Create tables then migrate legacy SQLite schemas
257
  Base.metadata.create_all(bind=engine)
 
163
  tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True, index=True)
164
  name = Column(String, index=True)
165
  stage = Column(String, default="new") # new|discovery|proposal|negotiation|won|lost
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="")
 
253
  if "google_refresh_token" not in ucols:
254
  conn.execute(text("ALTER TABLE users ADD COLUMN google_refresh_token TEXT"))
255
 
256
+ insp = inspect(connection_engine)
257
+ if insp.has_table("crm_deals"):
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
264
  Base.metadata.create_all(bind=engine)
backend/app/main.py CHANGED
@@ -23,6 +23,8 @@ from .database import (
23
  get_db,
24
  SessionLocal,
25
  Tenant,
 
 
26
  UploadedFile,
27
  Prompt,
28
  GeneratedSequence,
@@ -498,11 +500,41 @@ def _deal_name_from_lead(lead: CrmLead) -> str:
498
  return (lead.email or "").strip() or "Untitled deal"
499
 
500
 
501
- def _owner_initials_from_lead(lead: CrmLead) -> str:
502
- a = (lead.first_name or "").strip()[:1].upper()
503
- b = (lead.last_name or "").strip()[:1].upper()
504
- s = a + b
505
- return s if s else "?"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
506
 
507
 
508
  def _deal_to_dict(d: CrmDeal) -> dict:
@@ -519,6 +551,7 @@ def _deal_to_dict(d: CrmDeal) -> dict:
519
  "id": d.id,
520
  "name": d.name or "",
521
  "stage": d.stage or "new",
 
522
  "owner_initials": d.owner_initials or "",
523
  "deal_value": d.deal_value,
524
  "contact_display": d.contact_display or "",
@@ -1172,6 +1205,14 @@ def _format_contact_display(contact: Contact) -> str:
1172
 
1173
  def _enrich_deal_response(db: Session, row: CrmDeal) -> dict:
1174
  out = _deal_to_dict(row)
 
 
 
 
 
 
 
 
1175
  out["linked_contact"] = None
1176
  if row.contact_id:
1177
  c = (
@@ -2478,11 +2519,13 @@ async def deals_from_leads(body: BulkLeadIdsRequest, t: TenantContext = Depends(
2478
  errors.append({"lead_id": lid, "error": "not found"})
2479
  continue
2480
  person = " ".join(filter(None, [lead.first_name or "", lead.last_name or ""])).strip()
 
2481
  deal = CrmDeal(
2482
  tenant_id=lead.tenant_id,
2483
  name=_deal_name_from_lead(lead),
2484
  stage="new",
2485
- owner_initials=_owner_initials_from_lead(lead),
 
2486
  deal_value=None,
2487
  contact_display=person or (lead.email or ""),
2488
  account_name=lead.company_name or "",
@@ -2496,7 +2539,12 @@ async def deals_from_leads(body: BulkLeadIdsRequest, t: TenantContext = Depends(
2496
  )
2497
  db.add(deal)
2498
  db.flush()
2499
- created.append(_deal_to_dict(deal))
 
 
 
 
 
2500
  db.delete(lead)
2501
  db.commit()
2502
  return {"created": created, "errors": errors}
@@ -2513,6 +2561,10 @@ async def list_deals(
2513
  ):
2514
  db = t.db
2515
  q = db.query(CrmDeal).filter(CrmDeal.tenant_id == t.tenant_id)
 
 
 
 
2516
  if search.strip():
2517
  term = f"%{search.strip().lower()}%"
2518
  q = q.filter(
@@ -2536,7 +2588,23 @@ async def list_deals(
2536
  else:
2537
  q = q.order_by(col.desc())
2538
  rows = q.offset(offset).limit(limit).all()
2539
- return {"total": total, "deals": [_deal_to_dict(r) for r in rows]}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2540
 
2541
 
2542
  @app.post("/api/deals")
@@ -2550,11 +2618,13 @@ async def create_deal(body: CrmDealCreateRequest, t: TenantContext = Depends(get
2550
  db = t.db
2551
  raw_name = _safe_str(body.name) if body.name is not None else ""
2552
  name = raw_name or "Untitled deal"
 
2553
  row = CrmDeal(
2554
  tenant_id=t.tenant_id,
2555
  name=name,
2556
  stage=stage,
2557
- owner_initials="",
 
2558
  deal_value=None,
2559
  contact_display="",
2560
  account_name="",
@@ -2569,7 +2639,7 @@ async def create_deal(body: CrmDealCreateRequest, t: TenantContext = Depends(get
2569
  db.add(row)
2570
  db.commit()
2571
  db.refresh(row)
2572
- return _deal_to_dict(row)
2573
 
2574
 
2575
  @app.get("/api/deals/{deal_id}")
@@ -2582,6 +2652,7 @@ async def get_deal(deal_id: int, t: TenantContext = Depends(get_tenant_context))
2582
  )
2583
  if not row:
2584
  raise HTTPException(status_code=404, detail="Deal not found")
 
2585
  return _enrich_deal_response(db, row)
2586
 
2587
 
@@ -2595,9 +2666,49 @@ async def patch_deal(deal_id: int, body: CrmDealPatchRequest, t: TenantContext =
2595
  )
2596
  if not row:
2597
  raise HTTPException(status_code=404, detail="Deal not found")
 
2598
  data = body.model_dump(exclude_unset=True)
2599
  if not data:
2600
  raise HTTPException(status_code=400, detail="No fields to update")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2601
  if "contact_id" in data:
2602
  cid = data["contact_id"]
2603
  if cid is None:
@@ -2619,8 +2730,6 @@ async def patch_deal(deal_id: int, body: CrmDealPatchRequest, t: TenantContext =
2619
  row.account_name = _safe_str(contact.company)
2620
  if "name" in data:
2621
  row.name = _safe_str(data["name"])
2622
- if "owner_initials" in data:
2623
- row.owner_initials = _safe_str(data["owner_initials"])[:8]
2624
  if "contact_display" in data:
2625
  row.contact_display = _safe_str(data["contact_display"])
2626
  if "account_name" in data:
@@ -2688,7 +2797,7 @@ async def seed_demo_deals(t: TenantContext = Depends(get_tenant_context)):
2688
  {
2689
  "name": "DEMO: Ramco — Aus",
2690
  "stage": "discovery",
2691
- "owner_initials": "KW",
2692
  "deal_value": 10000,
2693
  "contact_display": "KENNETH WON",
2694
  "account_name": "Quanticsit",
@@ -2699,7 +2808,7 @@ async def seed_demo_deals(t: TenantContext = Depends(get_tenant_context)):
2699
  {
2700
  "name": "DEMO: HT Pilot",
2701
  "stage": "proposal",
2702
- "owner_initials": "SL",
2703
  "deal_value": 1600,
2704
  "contact_display": "SARAH LEE",
2705
  "account_name": "HT Logistics",
@@ -2710,7 +2819,7 @@ async def seed_demo_deals(t: TenantContext = Depends(get_tenant_context)):
2710
  {
2711
  "name": "DEMO: Northwind rollout",
2712
  "stage": "negotiation",
2713
- "owner_initials": "JD",
2714
  "deal_value": 45000,
2715
  "contact_display": "JAMES DIAZ",
2716
  "account_name": "Northwind Trading",
@@ -2721,7 +2830,7 @@ async def seed_demo_deals(t: TenantContext = Depends(get_tenant_context)):
2721
  {
2722
  "name": "DEMO: Maple cold chain",
2723
  "stage": "new",
2724
- "owner_initials": "AR",
2725
  "deal_value": None,
2726
  "contact_display": "ALEX RUIZ",
2727
  "account_name": "Maple Cold",
@@ -2736,6 +2845,7 @@ async def seed_demo_deals(t: TenantContext = Depends(get_tenant_context)):
2736
  tenant_id=tid,
2737
  name=s["name"],
2738
  stage=s["stage"],
 
2739
  owner_initials=s["owner_initials"],
2740
  deal_value=s["deal_value"],
2741
  contact_display=s["contact_display"],
 
23
  get_db,
24
  SessionLocal,
25
  Tenant,
26
+ TenantMembership,
27
+ User,
28
  UploadedFile,
29
  Prompt,
30
  GeneratedSequence,
 
500
  return (lead.email or "").strip() or "Untitled deal"
501
 
502
 
503
+ def _user_initials_from_user(u: User | None) -> str:
504
+ if not u:
505
+ return ""
506
+ name = (u.name or "").strip()
507
+ em = (u.email or "").strip()
508
+ if name:
509
+ parts = name.split()
510
+ if len(parts) >= 2:
511
+ return (parts[0][:1] + parts[-1][:1]).upper()[:2]
512
+ return (parts[0][:2] or "?").upper()[:2]
513
+ if em:
514
+ return em[:2].upper()
515
+ return "?"
516
+
517
+
518
+ def _tenant_member_ids(db: Session, tenant_id: int) -> set[int]:
519
+ rows = (
520
+ db.query(TenantMembership.user_id)
521
+ .filter(TenantMembership.tenant_id == int(tenant_id))
522
+ .all()
523
+ )
524
+ return {int(r[0]) for r in rows}
525
+
526
+
527
+ def _deal_member_can_see(row: CrmDeal, user_id: int) -> bool:
528
+ if row.owner_user_id is None:
529
+ return True
530
+ return int(row.owner_user_id) == int(user_id)
531
+
532
+
533
+ def _deal_access_or_403(row: CrmDeal, t: TenantContext) -> None:
534
+ if t.role == "admin":
535
+ return
536
+ if not _deal_member_can_see(row, t.user_id):
537
+ raise HTTPException(status_code=403, detail="You do not have access to this deal")
538
 
539
 
540
  def _deal_to_dict(d: CrmDeal) -> dict:
 
551
  "id": d.id,
552
  "name": d.name or "",
553
  "stage": d.stage or "new",
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 "",
 
1205
 
1206
  def _enrich_deal_response(db: Session, row: CrmDeal) -> dict:
1207
  out = _deal_to_dict(row)
1208
+ if row.owner_user_id:
1209
+ ou = db.query(User).filter(User.id == row.owner_user_id).first()
1210
+ if ou:
1211
+ out["owner_display_name"] = (ou.name or ou.email or "").strip() or None
1212
+ if not (out.get("owner_initials") or "").strip():
1213
+ out["owner_initials"] = _user_initials_from_user(ou)
1214
+ else:
1215
+ out["owner_display_name"] = None
1216
  out["linked_contact"] = None
1217
  if row.contact_id:
1218
  c = (
 
2519
  errors.append({"lead_id": lid, "error": "not found"})
2520
  continue
2521
  person = " ".join(filter(None, [lead.first_name or "", lead.last_name or ""])).strip()
2522
+ inviter = db.query(User).filter(User.id == t.user_id).first()
2523
  deal = CrmDeal(
2524
  tenant_id=lead.tenant_id,
2525
  name=_deal_name_from_lead(lead),
2526
  stage="new",
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 "",
 
2539
  )
2540
  db.add(deal)
2541
  db.flush()
2542
+ dd = _deal_to_dict(deal)
2543
+ if inviter:
2544
+ dd["owner_display_name"] = (inviter.name or inviter.email or "").strip() or None
2545
+ else:
2546
+ dd["owner_display_name"] = None
2547
+ created.append(dd)
2548
  db.delete(lead)
2549
  db.commit()
2550
  return {"created": created, "errors": errors}
 
2561
  ):
2562
  db = t.db
2563
  q = db.query(CrmDeal).filter(CrmDeal.tenant_id == t.tenant_id)
2564
+ if t.role != "admin":
2565
+ q = q.filter(
2566
+ or_(CrmDeal.owner_user_id == t.user_id, CrmDeal.owner_user_id.is_(None))
2567
+ )
2568
  if search.strip():
2569
  term = f"%{search.strip().lower()}%"
2570
  q = q.filter(
 
2588
  else:
2589
  q = q.order_by(col.desc())
2590
  rows = q.offset(offset).limit(limit).all()
2591
+ oid_set = {r.owner_user_id for r in rows if r.owner_user_id}
2592
+ user_map: Dict[int, User] = {}
2593
+ if oid_set:
2594
+ for u in db.query(User).filter(User.id.in_(oid_set)).all():
2595
+ user_map[u.id] = u
2596
+ deals_out = []
2597
+ for r in rows:
2598
+ dd = _deal_to_dict(r)
2599
+ if r.owner_user_id and r.owner_user_id in user_map:
2600
+ u = user_map[r.owner_user_id]
2601
+ dd["owner_display_name"] = (u.name or u.email or "").strip() or None
2602
+ if not (dd.get("owner_initials") or "").strip():
2603
+ dd["owner_initials"] = _user_initials_from_user(u)
2604
+ else:
2605
+ dd["owner_display_name"] = None
2606
+ deals_out.append(dd)
2607
+ return {"total": total, "deals": deals_out}
2608
 
2609
 
2610
  @app.post("/api/deals")
 
2618
  db = t.db
2619
  raw_name = _safe_str(body.name) if body.name is not None else ""
2620
  name = raw_name or "Untitled deal"
2621
+ creator = db.query(User).filter(User.id == t.user_id).first()
2622
  row = CrmDeal(
2623
  tenant_id=t.tenant_id,
2624
  name=name,
2625
  stage=stage,
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="",
 
2639
  db.add(row)
2640
  db.commit()
2641
  db.refresh(row)
2642
+ return _enrich_deal_response(db, row)
2643
 
2644
 
2645
  @app.get("/api/deals/{deal_id}")
 
2652
  )
2653
  if not row:
2654
  raise HTTPException(status_code=404, detail="Deal not found")
2655
+ _deal_access_or_403(row, t)
2656
  return _enrich_deal_response(db, row)
2657
 
2658
 
 
2666
  )
2667
  if not row:
2668
  raise HTTPException(status_code=404, detail="Deal not found")
2669
+ _deal_access_or_403(row, t)
2670
  data = body.model_dump(exclude_unset=True)
2671
  if not data:
2672
  raise HTTPException(status_code=400, detail="No fields to update")
2673
+ if "owner_initials" in data:
2674
+ del data["owner_initials"]
2675
+ if "owner_user_id" in data:
2676
+ val = data.pop("owner_user_id")
2677
+ mids = _tenant_member_ids(db, t.tenant_id)
2678
+ if t.role == "admin":
2679
+ if val is None:
2680
+ row.owner_user_id = None
2681
+ row.owner_initials = ""
2682
+ else:
2683
+ vid = int(val)
2684
+ if vid not in mids:
2685
+ raise HTTPException(
2686
+ status_code=400,
2687
+ detail="Owner must be a member of this workspace",
2688
+ )
2689
+ ou = db.query(User).filter(User.id == vid).first()
2690
+ if not ou:
2691
+ raise HTTPException(status_code=404, detail="User not found")
2692
+ row.owner_user_id = vid
2693
+ row.owner_initials = _user_initials_from_user(ou)
2694
+ else:
2695
+ if val is None:
2696
+ raise HTTPException(
2697
+ status_code=403, detail="Only admins can set a deal to unassigned"
2698
+ )
2699
+ if int(val) != int(t.user_id):
2700
+ raise HTTPException(
2701
+ status_code=403,
2702
+ detail="Only admins can assign deals to other users",
2703
+ )
2704
+ if row.owner_user_id is not None and int(row.owner_user_id) != int(t.user_id):
2705
+ raise HTTPException(
2706
+ status_code=403,
2707
+ detail="You can only take ownership of unassigned deals",
2708
+ )
2709
+ claimer = db.query(User).filter(User.id == t.user_id).first()
2710
+ row.owner_user_id = t.user_id
2711
+ row.owner_initials = _user_initials_from_user(claimer)
2712
  if "contact_id" in data:
2713
  cid = data["contact_id"]
2714
  if cid is None:
 
2730
  row.account_name = _safe_str(contact.company)
2731
  if "name" in data:
2732
  row.name = _safe_str(data["name"])
 
 
2733
  if "contact_display" in data:
2734
  row.contact_display = _safe_str(data["contact_display"])
2735
  if "account_name" in data:
 
2797
  {
2798
  "name": "DEMO: Ramco — Aus",
2799
  "stage": "discovery",
2800
+ "owner_initials": "",
2801
  "deal_value": 10000,
2802
  "contact_display": "KENNETH WON",
2803
  "account_name": "Quanticsit",
 
2808
  {
2809
  "name": "DEMO: HT Pilot",
2810
  "stage": "proposal",
2811
+ "owner_initials": "",
2812
  "deal_value": 1600,
2813
  "contact_display": "SARAH LEE",
2814
  "account_name": "HT Logistics",
 
2819
  {
2820
  "name": "DEMO: Northwind rollout",
2821
  "stage": "negotiation",
2822
+ "owner_initials": "",
2823
  "deal_value": 45000,
2824
  "contact_display": "JAMES DIAZ",
2825
  "account_name": "Northwind Trading",
 
2830
  {
2831
  "name": "DEMO: Maple cold chain",
2832
  "stage": "new",
2833
+ "owner_initials": "",
2834
  "deal_value": None,
2835
  "contact_display": "ALEX RUIZ",
2836
  "account_name": "Maple Cold",
 
2845
  tenant_id=tid,
2846
  name=s["name"],
2847
  stage=s["stage"],
2848
+ owner_user_id=None,
2849
  owner_initials=s["owner_initials"],
2850
  deal_value=s["deal_value"],
2851
  contact_display=s["contact_display"],
backend/app/models.py CHANGED
@@ -99,6 +99,8 @@ class CrmDealPatchRequest(BaseModel):
99
  name: Optional[str] = None
100
  stage: Optional[str] = None
101
  owner_initials: Optional[str] = None
 
 
102
  deal_value: Optional[int] = None
103
  close_probability: Optional[int] = None
104
  expected_close_date: Optional[str] = None # ISO date or datetime
 
99
  name: Optional[str] = None
100
  stage: Optional[str] = None
101
  owner_initials: 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
  close_probability: Optional[int] = None
106
  expected_close_date: Optional[str] = None # ISO date or datetime
frontend/src/pages/Deals.jsx CHANGED
@@ -143,6 +143,9 @@ function GroupedDealTbody({
143
  openDeal,
144
  createBusy,
145
  onAddDeal,
 
 
 
146
  }) {
147
  const sumDeal = sumNumeric(deals, 'deal_value');
148
  const sumForecast = sumNumeric(deals, 'forecast_value');
@@ -162,6 +165,9 @@ function GroupedDealTbody({
162
  patchDeal={patchDeal}
163
  updateStage={updateStage}
164
  openDeal={openDeal}
 
 
 
165
  />
166
  ))}
167
  <tr className="bg-slate-50/90 text-slate-700 border-b border-slate-100">
@@ -350,7 +356,17 @@ function PipelineBoard({ columns, openDeal, patchDeal, createDeal, createBusy })
350
  );
351
  }
352
 
353
- function DealRow({ deal, tableEditRowId, setTableEditRowId, patchDeal, updateStage, openDeal }) {
 
 
 
 
 
 
 
 
 
 
354
  const meta = stageMeta(deal.stage);
355
  const safeStage = STAGES.some((s) => s.value === deal.stage) ? deal.stage : 'new';
356
  const closeSel = closeProbabilitySelectValue(deal.close_probability);
@@ -441,20 +457,64 @@ function DealRow({ deal, tableEditRowId, setTableEditRowId, patchDeal, updateSta
441
  </SelectContent>
442
  </Select>
443
  </td>
444
- <td className="px-3 py-2 align-top">
445
- {tableEditRowId === deal.id ? (
446
- <EditableCell
447
- value={deal.owner_initials || ''}
448
- onCommit={(v) =>
449
- patchDeal(deal.id, {
450
- owner_initials: (v || '').slice(0, 8).toUpperCase(),
451
- })
452
  }
453
- inputClassName="text-center uppercase tracking-wide max-w-[4.5rem] font-semibold"
454
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
455
  ) : (
456
- <div className="h-8 w-8 rounded-full bg-violet-100 text-violet-700 text-xs font-semibold flex items-center justify-center border border-violet-200">
457
- {deal.owner_initials || '?'}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
458
  </div>
459
  )}
460
  </td>
@@ -602,6 +662,29 @@ export default function Deals() {
602
  const [companyFetchError, setCompanyFetchError] = useState('');
603
  const [dealPanelForm, setDealPanelForm] = useState(null);
604
  const [dealPanelSaving, setDealPanelSaving] = useState(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
605
 
606
  const fetchDeals = useCallback(async () => {
607
  setLoading(true);
@@ -666,11 +749,17 @@ export default function Deals() {
666
  const dealsByOwner = useMemo(() => {
667
  const map = new Map();
668
  for (const d of deals) {
669
- const raw = (d.owner_initials || '').trim();
670
- const sortKey = raw ? raw.toUpperCase() : EMPTY_OWNER_KEY;
 
 
 
 
 
 
 
671
  if (!map.has(sortKey)) {
672
- const label = raw ? raw.toUpperCase() : 'Unassigned';
673
- map.set(sortKey, { sortKey, displayLabel: label, deals: [] });
674
  }
675
  map.get(sortKey).deals.push(d);
676
  }
@@ -888,7 +977,7 @@ export default function Deals() {
888
  return (
889
  <AppShell
890
  title="Deals"
891
- subtitle="Pipeline from converted leads stage, value, and forecast."
892
  >
893
  <MainTableWorkspace
894
  tabs={[
@@ -1007,6 +1096,9 @@ export default function Deals() {
1007
  patchDeal={patchDeal}
1008
  updateStage={updateStage}
1009
  openDeal={openDeal}
 
 
 
1010
  />
1011
  ))}
1012
  </tbody>
@@ -1042,6 +1134,9 @@ export default function Deals() {
1042
  openDeal={openDeal}
1043
  createBusy={createBusy}
1044
  onAddDeal={() => createDeal(group.value)}
 
 
 
1045
  />
1046
  ))
1047
  ) : dealsView === 'byCountry' ? (
@@ -1065,6 +1160,9 @@ export default function Deals() {
1065
  openDeal={openDeal}
1066
  createBusy={createBusy}
1067
  onAddDeal={() => createDeal('new')}
 
 
 
1068
  />
1069
  ))
1070
  ) : dealsView === 'byOwner' ? (
@@ -1079,7 +1177,7 @@ export default function Deals() {
1079
  <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-violet-100 text-xs font-semibold text-violet-800 border border-violet-200">
1080
  {group.sortKey === EMPTY_OWNER_KEY
1081
  ? '?'
1082
- : group.displayLabel.slice(0, 2)}
1083
  </div>
1084
  <span className="rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-semibold text-slate-800">
1085
  {group.displayLabel}
@@ -1098,6 +1196,9 @@ export default function Deals() {
1098
  openDeal={openDeal}
1099
  createBusy={createBusy}
1100
  onAddDeal={() => createDeal('new')}
 
 
 
1101
  />
1102
  ))
1103
  ) : null}
 
143
  openDeal,
144
  createBusy,
145
  onAddDeal,
146
+ isAdmin,
147
+ currentUserId,
148
+ tenantMembers,
149
  }) {
150
  const sumDeal = sumNumeric(deals, 'deal_value');
151
  const sumForecast = sumNumeric(deals, 'forecast_value');
 
165
  patchDeal={patchDeal}
166
  updateStage={updateStage}
167
  openDeal={openDeal}
168
+ isAdmin={isAdmin}
169
+ currentUserId={currentUserId}
170
+ tenantMembers={tenantMembers}
171
  />
172
  ))}
173
  <tr className="bg-slate-50/90 text-slate-700 border-b border-slate-100">
 
356
  );
357
  }
358
 
359
+ function DealRow({
360
+ deal,
361
+ tableEditRowId,
362
+ setTableEditRowId,
363
+ patchDeal,
364
+ updateStage,
365
+ openDeal,
366
+ isAdmin,
367
+ currentUserId,
368
+ tenantMembers,
369
+ }) {
370
  const meta = stageMeta(deal.stage);
371
  const safeStage = STAGES.some((s) => s.value === deal.stage) ? deal.stage : 'new';
372
  const closeSel = closeProbabilitySelectValue(deal.close_probability);
 
457
  </SelectContent>
458
  </Select>
459
  </td>
460
+ <td className="px-3 py-2 align-top" onClick={(e) => e.stopPropagation()}>
461
+ {isAdmin && tableEditRowId === deal.id ? (
462
+ <Select
463
+ value={
464
+ deal.owner_user_id != null && deal.owner_user_id !== ''
465
+ ? String(deal.owner_user_id)
466
+ : EMPTY_OWNER_KEY
 
467
  }
468
+ onValueChange={(v) => {
469
+ if (v === EMPTY_OWNER_KEY) {
470
+ patchDeal(deal.id, { owner_user_id: null });
471
+ } else {
472
+ patchDeal(deal.id, { owner_user_id: Number(v) });
473
+ }
474
+ }}
475
+ >
476
+ <SelectTrigger className="h-8 min-w-[10rem] border-slate-200 shadow-none text-xs">
477
+ <span className="truncate">
478
+ {deal.owner_user_id == null
479
+ ? 'Unassigned'
480
+ : deal.owner_display_name ||
481
+ deal.owner_initials ||
482
+ `User ${deal.owner_user_id}`}
483
+ </span>
484
+ </SelectTrigger>
485
+ <SelectContent className="min-w-[12rem]">
486
+ <SelectItem value={EMPTY_OWNER_KEY}>Unassigned</SelectItem>
487
+ {(tenantMembers || []).map((m) => (
488
+ <SelectItem key={m.user_id} value={String(m.user_id)}>
489
+ {(m.name || m.email || `User ${m.user_id}`).trim()}
490
+ </SelectItem>
491
+ ))}
492
+ </SelectContent>
493
+ </Select>
494
  ) : (
495
+ <div className="flex flex-wrap items-center gap-2">
496
+ <div
497
+ className="h-8 w-8 shrink-0 rounded-full bg-violet-100 text-violet-700 text-xs font-semibold flex items-center justify-center border border-violet-200"
498
+ title={deal.owner_display_name || deal.owner_initials || 'Unassigned'}
499
+ >
500
+ {(deal.owner_initials || '?').toString().slice(0, 2).toUpperCase()}
501
+ </div>
502
+ {!isAdmin &&
503
+ currentUserId != null &&
504
+ (deal.owner_user_id == null || deal.owner_user_id === '') ? (
505
+ <Button
506
+ type="button"
507
+ variant="outline"
508
+ size="sm"
509
+ className="h-7 text-xs shrink-0"
510
+ onClick={(e) => {
511
+ e.stopPropagation();
512
+ patchDeal(deal.id, { owner_user_id: currentUserId });
513
+ }}
514
+ >
515
+ Take ownership
516
+ </Button>
517
+ ) : null}
518
  </div>
519
  )}
520
  </td>
 
662
  const [companyFetchError, setCompanyFetchError] = useState('');
663
  const [dealPanelForm, setDealPanelForm] = useState(null);
664
  const [dealPanelSaving, setDealPanelSaving] = useState(false);
665
+ const [me, setMe] = useState(null);
666
+ const [tenantMembers, setTenantMembers] = useState([]);
667
+
668
+ const isAdmin = me?.current_role === 'admin';
669
+ const currentUserId = me?.user_id ?? null;
670
+
671
+ useEffect(() => {
672
+ apiFetch('/api/auth/me')
673
+ .then((r) => (r.ok ? r.json() : null))
674
+ .then(setMe)
675
+ .catch(() => setMe(null));
676
+ }, []);
677
+
678
+ useEffect(() => {
679
+ if (!me || me.current_role !== 'admin') {
680
+ setTenantMembers([]);
681
+ return;
682
+ }
683
+ apiFetch('/api/tenants/members')
684
+ .then((r) => (r.ok ? r.json() : null))
685
+ .then((d) => setTenantMembers(d?.members || []))
686
+ .catch(() => setTenantMembers([]));
687
+ }, [me]);
688
 
689
  const fetchDeals = useCallback(async () => {
690
  setLoading(true);
 
749
  const dealsByOwner = useMemo(() => {
750
  const map = new Map();
751
  for (const d of deals) {
752
+ const unassigned = d.owner_user_id == null;
753
+ const sortKey = unassigned ? EMPTY_OWNER_KEY : `u:${d.owner_user_id}`;
754
+ const displayLabel = unassigned
755
+ ? 'Unassigned'
756
+ : (
757
+ d.owner_display_name ||
758
+ d.owner_initials ||
759
+ `User ${d.owner_user_id}`
760
+ ).trim();
761
  if (!map.has(sortKey)) {
762
+ map.set(sortKey, { sortKey, displayLabel, deals: [] });
 
763
  }
764
  map.get(sortKey).deals.push(d);
765
  }
 
977
  return (
978
  <AppShell
979
  title="Deals"
980
+ subtitle="Pipeline by stage and owner. Members see their deals and unassigned deals; admins see all and can assign owners (edit row → Owner)."
981
  >
982
  <MainTableWorkspace
983
  tabs={[
 
1096
  patchDeal={patchDeal}
1097
  updateStage={updateStage}
1098
  openDeal={openDeal}
1099
+ isAdmin={isAdmin}
1100
+ currentUserId={currentUserId}
1101
+ tenantMembers={tenantMembers}
1102
  />
1103
  ))}
1104
  </tbody>
 
1134
  openDeal={openDeal}
1135
  createBusy={createBusy}
1136
  onAddDeal={() => createDeal(group.value)}
1137
+ isAdmin={isAdmin}
1138
+ currentUserId={currentUserId}
1139
+ tenantMembers={tenantMembers}
1140
  />
1141
  ))
1142
  ) : dealsView === 'byCountry' ? (
 
1160
  openDeal={openDeal}
1161
  createBusy={createBusy}
1162
  onAddDeal={() => createDeal('new')}
1163
+ isAdmin={isAdmin}
1164
+ currentUserId={currentUserId}
1165
+ tenantMembers={tenantMembers}
1166
  />
1167
  ))
1168
  ) : dealsView === 'byOwner' ? (
 
1177
  <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-violet-100 text-xs font-semibold text-violet-800 border border-violet-200">
1178
  {group.sortKey === EMPTY_OWNER_KEY
1179
  ? '?'
1180
+ : group.displayLabel.slice(0, 2).toUpperCase()}
1181
  </div>
1182
  <span className="rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-semibold text-slate-800">
1183
  {group.displayLabel}
 
1196
  openDeal={openDeal}
1197
  createBusy={createBusy}
1198
  onAddDeal={() => createDeal('new')}
1199
+ isAdmin={isAdmin}
1200
+ currentUserId={currentUserId}
1201
+ tenantMembers={tenantMembers}
1202
  />
1203
  ))
1204
  ) : null}