Seth commited on
Commit ·
bc88140
1
Parent(s): 897d4ad
update
Browse files- backend/app/main.py +16 -0
- frontend/src/pages/Deals.jsx +369 -5
backend/app/main.py
CHANGED
|
@@ -2798,6 +2798,22 @@ async def patch_deal(deal_id: int, body: CrmDealPatchRequest, t: TenantContext =
|
|
| 2798 |
return _enrich_deal_response(db, row)
|
| 2799 |
|
| 2800 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2801 |
@app.post("/api/deals/seed-demo")
|
| 2802 |
async def seed_demo_deals(t: TenantContext = Depends(get_tenant_context)):
|
| 2803 |
db = t.db
|
|
|
|
| 2798 |
return _enrich_deal_response(db, row)
|
| 2799 |
|
| 2800 |
|
| 2801 |
+
@app.delete("/api/deals/{deal_id}")
|
| 2802 |
+
async def delete_deal(deal_id: int, t: TenantContext = Depends(get_tenant_context)):
|
| 2803 |
+
db = t.db
|
| 2804 |
+
row = (
|
| 2805 |
+
db.query(CrmDeal)
|
| 2806 |
+
.filter(CrmDeal.tenant_id == t.tenant_id, CrmDeal.id == deal_id)
|
| 2807 |
+
.first()
|
| 2808 |
+
)
|
| 2809 |
+
if not row:
|
| 2810 |
+
raise HTTPException(status_code=404, detail="Deal not found")
|
| 2811 |
+
_deal_access_or_403(row, t)
|
| 2812 |
+
db.delete(row)
|
| 2813 |
+
db.commit()
|
| 2814 |
+
return {"ok": True, "id": deal_id}
|
| 2815 |
+
|
| 2816 |
+
|
| 2817 |
@app.post("/api/deals/seed-demo")
|
| 2818 |
async def seed_demo_deals(t: TenantContext = Depends(get_tenant_context)):
|
| 2819 |
db = t.db
|
frontend/src/pages/Deals.jsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
-
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
| 2 |
import { Link } from 'react-router-dom';
|
| 3 |
-
import { GitBranch, Loader2, LayoutGrid, MessageSquare } from 'lucide-react';
|
| 4 |
import { Button } from '@/components/ui/button';
|
| 5 |
import { Select, SelectTrigger, SelectContent, SelectItem } from '@/components/ui/select';
|
| 6 |
import AppShell from '@/components/layout/AppShell';
|
|
@@ -28,6 +28,8 @@ const STAGES = [
|
|
| 28 |
const EMPTY_COUNTRY_KEY = '__country_none__';
|
| 29 |
const EMPTY_OWNER_KEY = '__owner_none__';
|
| 30 |
const MEMBER_TAKE_OWNERSHIP_VALUE = '__member_take_ownership__';
|
|
|
|
|
|
|
| 31 |
|
| 32 |
/** Accent bars for By Country / By Owner groups (rotate). */
|
| 33 |
const GROUP_BAR_ROTATING = [
|
|
@@ -150,6 +152,8 @@ function GroupedDealTbody({
|
|
| 150 |
isAdmin,
|
| 151 |
currentUserId,
|
| 152 |
tenantMembers,
|
|
|
|
|
|
|
| 153 |
}) {
|
| 154 |
const sumDeal = sumNumeric(deals, 'deal_value');
|
| 155 |
const sumForecast = sumNumeric(deals, 'forecast_value');
|
|
@@ -170,6 +174,8 @@ function GroupedDealTbody({
|
|
| 170 |
isAdmin={isAdmin}
|
| 171 |
currentUserId={currentUserId}
|
| 172 |
tenantMembers={tenantMembers}
|
|
|
|
|
|
|
| 173 |
/>
|
| 174 |
))}
|
| 175 |
<tr className="bg-slate-50/90 text-slate-700 border-b border-slate-100">
|
|
@@ -360,7 +366,17 @@ function PipelineBoard({ columns, openDeal, patchDeal, createDeal, createBusy })
|
|
| 360 |
);
|
| 361 |
}
|
| 362 |
|
| 363 |
-
function DealRow({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 364 |
const meta = stageMeta(deal.stage);
|
| 365 |
const safeStage = STAGES.some((s) => s.value === deal.stage) ? deal.stage : 'new';
|
| 366 |
const closeSel = closeProbabilitySelectValue(deal.close_probability);
|
|
@@ -383,7 +399,16 @@ function DealRow({ deal, patchDeal, updateStage, openDeal, isAdmin, currentUserI
|
|
| 383 |
className="border-b border-slate-100 hover:bg-violet-50/40 cursor-pointer"
|
| 384 |
>
|
| 385 |
<td className="px-2 py-2 w-10 text-center align-middle" onClick={(e) => e.stopPropagation()}>
|
| 386 |
-
<input
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 387 |
</td>
|
| 388 |
<td
|
| 389 |
className="px-3 py-2 align-top min-w-[16rem] max-w-[min(28rem,40vw)] font-medium"
|
|
@@ -672,6 +697,11 @@ export default function Deals() {
|
|
| 672 |
const [companyFetchError, setCompanyFetchError] = useState('');
|
| 673 |
const [me, setMe] = useState(null);
|
| 674 |
const [tenantMembers, setTenantMembers] = useState([]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 675 |
|
| 676 |
const isAdmin = me?.current_role === 'admin';
|
| 677 |
const currentUserId = me?.user_id ?? null;
|
|
@@ -721,6 +751,43 @@ export default function Deals() {
|
|
| 721 |
return () => clearTimeout(t);
|
| 722 |
}, [fetchDeals]);
|
| 723 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 724 |
const dealsByStage = useMemo(() => {
|
| 725 |
return STAGES.map((s) => ({
|
| 726 |
...s,
|
|
@@ -936,9 +1003,153 @@ export default function Deals() {
|
|
| 936 |
setDealDetail(null);
|
| 937 |
};
|
| 938 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 939 |
const dealPanelCloseSel =
|
| 940 |
dealDetail != null ? closeProbabilitySelectValue(dealDetail.close_probability) : '__clear__';
|
| 941 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 942 |
return (
|
| 943 |
<AppShell
|
| 944 |
title="Deals"
|
|
@@ -997,6 +1208,137 @@ export default function Deals() {
|
|
| 997 |
sectionCount={total}
|
| 998 |
sectionOpen={sectionOpen}
|
| 999 |
onSectionToggle={() => setSectionOpen(!sectionOpen)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1000 |
>
|
| 1001 |
<div className="w-full min-w-0 overflow-x-auto">
|
| 1002 |
{loading ? (
|
|
@@ -1036,7 +1378,21 @@ export default function Deals() {
|
|
| 1036 |
<table className="w-full text-sm min-w-[1120px]">
|
| 1037 |
<thead>
|
| 1038 |
<tr className="bg-slate-50 text-left text-slate-600 border-b border-slate-200">
|
| 1039 |
-
<th className="px-2 py-2 w-10
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1040 |
<th className="px-3 py-2 font-medium min-w-[16rem] max-w-[min(28rem,40vw)]">
|
| 1041 |
Deal
|
| 1042 |
</th>
|
|
@@ -1067,6 +1423,8 @@ export default function Deals() {
|
|
| 1067 |
isAdmin={isAdmin}
|
| 1068 |
currentUserId={currentUserId}
|
| 1069 |
tenantMembers={tenantMembers}
|
|
|
|
|
|
|
| 1070 |
/>
|
| 1071 |
))}
|
| 1072 |
</tbody>
|
|
@@ -1103,6 +1461,8 @@ export default function Deals() {
|
|
| 1103 |
isAdmin={isAdmin}
|
| 1104 |
currentUserId={currentUserId}
|
| 1105 |
tenantMembers={tenantMembers}
|
|
|
|
|
|
|
| 1106 |
/>
|
| 1107 |
))
|
| 1108 |
) : dealsView === 'byCountry' ? (
|
|
@@ -1127,6 +1487,8 @@ export default function Deals() {
|
|
| 1127 |
isAdmin={isAdmin}
|
| 1128 |
currentUserId={currentUserId}
|
| 1129 |
tenantMembers={tenantMembers}
|
|
|
|
|
|
|
| 1130 |
/>
|
| 1131 |
))
|
| 1132 |
) : dealsView === 'byOwner' ? (
|
|
@@ -1161,6 +1523,8 @@ export default function Deals() {
|
|
| 1161 |
isAdmin={isAdmin}
|
| 1162 |
currentUserId={currentUserId}
|
| 1163 |
tenantMembers={tenantMembers}
|
|
|
|
|
|
|
| 1164 |
/>
|
| 1165 |
))
|
| 1166 |
) : null}
|
|
|
|
| 1 |
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
| 2 |
import { Link } from 'react-router-dom';
|
| 3 |
+
import { GitBranch, Loader2, LayoutGrid, MessageSquare, Trash2, UserPlus } from 'lucide-react';
|
| 4 |
import { Button } from '@/components/ui/button';
|
| 5 |
import { Select, SelectTrigger, SelectContent, SelectItem } from '@/components/ui/select';
|
| 6 |
import AppShell from '@/components/layout/AppShell';
|
|
|
|
| 28 |
const EMPTY_COUNTRY_KEY = '__country_none__';
|
| 29 |
const EMPTY_OWNER_KEY = '__owner_none__';
|
| 30 |
const MEMBER_TAKE_OWNERSHIP_VALUE = '__member_take_ownership__';
|
| 31 |
+
/** Bulk toolbar: no owner/stage chosen yet */
|
| 32 |
+
const BULK_NONE = '__bulk_none__';
|
| 33 |
|
| 34 |
/** Accent bars for By Country / By Owner groups (rotate). */
|
| 35 |
const GROUP_BAR_ROTATING = [
|
|
|
|
| 152 |
isAdmin,
|
| 153 |
currentUserId,
|
| 154 |
tenantMembers,
|
| 155 |
+
selectedDealIds,
|
| 156 |
+
onToggleDealSelected,
|
| 157 |
}) {
|
| 158 |
const sumDeal = sumNumeric(deals, 'deal_value');
|
| 159 |
const sumForecast = sumNumeric(deals, 'forecast_value');
|
|
|
|
| 174 |
isAdmin={isAdmin}
|
| 175 |
currentUserId={currentUserId}
|
| 176 |
tenantMembers={tenantMembers}
|
| 177 |
+
selected={selectedDealIds.includes(deal.id)}
|
| 178 |
+
onToggleSelected={onToggleDealSelected}
|
| 179 |
/>
|
| 180 |
))}
|
| 181 |
<tr className="bg-slate-50/90 text-slate-700 border-b border-slate-100">
|
|
|
|
| 366 |
);
|
| 367 |
}
|
| 368 |
|
| 369 |
+
function DealRow({
|
| 370 |
+
deal,
|
| 371 |
+
patchDeal,
|
| 372 |
+
updateStage,
|
| 373 |
+
openDeal,
|
| 374 |
+
isAdmin,
|
| 375 |
+
currentUserId,
|
| 376 |
+
tenantMembers,
|
| 377 |
+
selected,
|
| 378 |
+
onToggleSelected,
|
| 379 |
+
}) {
|
| 380 |
const meta = stageMeta(deal.stage);
|
| 381 |
const safeStage = STAGES.some((s) => s.value === deal.stage) ? deal.stage : 'new';
|
| 382 |
const closeSel = closeProbabilitySelectValue(deal.close_probability);
|
|
|
|
| 399 |
className="border-b border-slate-100 hover:bg-violet-50/40 cursor-pointer"
|
| 400 |
>
|
| 401 |
<td className="px-2 py-2 w-10 text-center align-middle" onClick={(e) => e.stopPropagation()}>
|
| 402 |
+
<input
|
| 403 |
+
type="checkbox"
|
| 404 |
+
className="rounded border-slate-300"
|
| 405 |
+
checked={selected}
|
| 406 |
+
onChange={(e) => {
|
| 407 |
+
e.stopPropagation();
|
| 408 |
+
onToggleSelected(deal.id);
|
| 409 |
+
}}
|
| 410 |
+
aria-label={`Select ${deal.name || 'deal'}`}
|
| 411 |
+
/>
|
| 412 |
</td>
|
| 413 |
<td
|
| 414 |
className="px-3 py-2 align-top min-w-[16rem] max-w-[min(28rem,40vw)] font-medium"
|
|
|
|
| 697 |
const [companyFetchError, setCompanyFetchError] = useState('');
|
| 698 |
const [me, setMe] = useState(null);
|
| 699 |
const [tenantMembers, setTenantMembers] = useState([]);
|
| 700 |
+
const [selectedDealIds, setSelectedDealIds] = useState([]);
|
| 701 |
+
const [bulkOwnerValue, setBulkOwnerValue] = useState(BULK_NONE);
|
| 702 |
+
const [bulkStageValue, setBulkStageValue] = useState(BULK_NONE);
|
| 703 |
+
const [bulkBusy, setBulkBusy] = useState(false);
|
| 704 |
+
const selectAllCheckboxRef = useRef(null);
|
| 705 |
|
| 706 |
const isAdmin = me?.current_role === 'admin';
|
| 707 |
const currentUserId = me?.user_id ?? null;
|
|
|
|
| 751 |
return () => clearTimeout(t);
|
| 752 |
}, [fetchDeals]);
|
| 753 |
|
| 754 |
+
useEffect(() => {
|
| 755 |
+
setSelectedDealIds((ids) => ids.filter((id) => deals.some((d) => d.id === id)));
|
| 756 |
+
}, [deals]);
|
| 757 |
+
|
| 758 |
+
useEffect(() => {
|
| 759 |
+
setSelectedDealIds([]);
|
| 760 |
+
setBulkOwnerValue(BULK_NONE);
|
| 761 |
+
setBulkStageValue(BULK_NONE);
|
| 762 |
+
}, [dealsView]);
|
| 763 |
+
|
| 764 |
+
useEffect(() => {
|
| 765 |
+
const el = selectAllCheckboxRef.current;
|
| 766 |
+
if (!el) return;
|
| 767 |
+
if (deals.length === 0) {
|
| 768 |
+
el.indeterminate = false;
|
| 769 |
+
return;
|
| 770 |
+
}
|
| 771 |
+
el.indeterminate = selectedDealIds.length > 0 && selectedDealIds.length < deals.length;
|
| 772 |
+
}, [deals, selectedDealIds]);
|
| 773 |
+
|
| 774 |
+
const onToggleDealSelected = useCallback((id) => {
|
| 775 |
+
setSelectedDealIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
| 776 |
+
}, []);
|
| 777 |
+
|
| 778 |
+
const toggleSelectAll = useCallback(() => {
|
| 779 |
+
setSelectedDealIds((prev) => {
|
| 780 |
+
if (deals.length === 0) return [];
|
| 781 |
+
const allOn = deals.every((d) => prev.includes(d.id));
|
| 782 |
+
if (allOn) return [];
|
| 783 |
+
return deals.map((d) => d.id);
|
| 784 |
+
});
|
| 785 |
+
}, [deals]);
|
| 786 |
+
|
| 787 |
+
const clearDealSelection = useCallback(() => {
|
| 788 |
+
setSelectedDealIds([]);
|
| 789 |
+
}, []);
|
| 790 |
+
|
| 791 |
const dealsByStage = useMemo(() => {
|
| 792 |
return STAGES.map((s) => ({
|
| 793 |
...s,
|
|
|
|
| 1003 |
setDealDetail(null);
|
| 1004 |
};
|
| 1005 |
|
| 1006 |
+
const bulkDeleteDeals = async () => {
|
| 1007 |
+
if (selectedDealIds.length === 0) return;
|
| 1008 |
+
if (!window.confirm(`Delete ${selectedDealIds.length} deal(s)? This cannot be undone.`)) return;
|
| 1009 |
+
setBulkBusy(true);
|
| 1010 |
+
const ids = [...selectedDealIds];
|
| 1011 |
+
try {
|
| 1012 |
+
const results = await Promise.allSettled(
|
| 1013 |
+
ids.map((id) =>
|
| 1014 |
+
apiFetch(`/api/deals/${id}`, { method: 'DELETE' }).then(async (res) => {
|
| 1015 |
+
if (!res.ok) {
|
| 1016 |
+
const data = await res.json().catch(() => ({}));
|
| 1017 |
+
throw new Error(
|
| 1018 |
+
typeof data.detail === 'string' ? data.detail : 'Delete failed'
|
| 1019 |
+
);
|
| 1020 |
+
}
|
| 1021 |
+
})
|
| 1022 |
+
)
|
| 1023 |
+
);
|
| 1024 |
+
const failed = results.filter((r) => r.status === 'rejected').length;
|
| 1025 |
+
if (failed) {
|
| 1026 |
+
alert(`${failed} delete(s) failed. The list will refresh.`);
|
| 1027 |
+
}
|
| 1028 |
+
if (dealDetail && ids.includes(dealDetail.id)) closePanel();
|
| 1029 |
+
setSelectedDealIds([]);
|
| 1030 |
+
await fetchDeals();
|
| 1031 |
+
} finally {
|
| 1032 |
+
setBulkBusy(false);
|
| 1033 |
+
}
|
| 1034 |
+
};
|
| 1035 |
+
|
| 1036 |
+
const bulkApplyOwner = async () => {
|
| 1037 |
+
if (!isAdmin || bulkOwnerValue === BULK_NONE || selectedDealIds.length === 0) return;
|
| 1038 |
+
const patch =
|
| 1039 |
+
bulkOwnerValue === EMPTY_OWNER_KEY
|
| 1040 |
+
? { owner_user_id: null }
|
| 1041 |
+
: { owner_user_id: Number(bulkOwnerValue) };
|
| 1042 |
+
setBulkBusy(true);
|
| 1043 |
+
try {
|
| 1044 |
+
await Promise.all(
|
| 1045 |
+
selectedDealIds.map((id) =>
|
| 1046 |
+
apiFetch(`/api/deals/${id}`, {
|
| 1047 |
+
method: 'PATCH',
|
| 1048 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1049 |
+
body: JSON.stringify(patch),
|
| 1050 |
+
}).then(async (res) => {
|
| 1051 |
+
const data = await res.json().catch(() => ({}));
|
| 1052 |
+
if (!res.ok) {
|
| 1053 |
+
throw new Error(
|
| 1054 |
+
typeof data.detail === 'string' ? data.detail : 'Update failed'
|
| 1055 |
+
);
|
| 1056 |
+
}
|
| 1057 |
+
return data;
|
| 1058 |
+
})
|
| 1059 |
+
)
|
| 1060 |
+
);
|
| 1061 |
+
setBulkOwnerValue(BULK_NONE);
|
| 1062 |
+
await fetchDeals();
|
| 1063 |
+
} catch (e) {
|
| 1064 |
+
console.error(e);
|
| 1065 |
+
alert(e.message || 'Could not assign owner');
|
| 1066 |
+
await fetchDeals();
|
| 1067 |
+
} finally {
|
| 1068 |
+
setBulkBusy(false);
|
| 1069 |
+
}
|
| 1070 |
+
};
|
| 1071 |
+
|
| 1072 |
+
const bulkTakeOwnership = async () => {
|
| 1073 |
+
if (currentUserId == null || selectedDealIds.length === 0) return;
|
| 1074 |
+
setBulkBusy(true);
|
| 1075 |
+
try {
|
| 1076 |
+
await Promise.all(
|
| 1077 |
+
selectedDealIds.map((id) =>
|
| 1078 |
+
apiFetch(`/api/deals/${id}`, {
|
| 1079 |
+
method: 'PATCH',
|
| 1080 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1081 |
+
body: JSON.stringify({ owner_user_id: currentUserId }),
|
| 1082 |
+
}).then(async (res) => {
|
| 1083 |
+
const data = await res.json().catch(() => ({}));
|
| 1084 |
+
if (!res.ok) {
|
| 1085 |
+
throw new Error(
|
| 1086 |
+
typeof data.detail === 'string' ? data.detail : 'Update failed'
|
| 1087 |
+
);
|
| 1088 |
+
}
|
| 1089 |
+
return data;
|
| 1090 |
+
})
|
| 1091 |
+
)
|
| 1092 |
+
);
|
| 1093 |
+
await fetchDeals();
|
| 1094 |
+
} catch (e) {
|
| 1095 |
+
console.error(e);
|
| 1096 |
+
alert(
|
| 1097 |
+
e.message ||
|
| 1098 |
+
'Could not take ownership of all selected deals. You can only claim unassigned deals you are allowed to edit.'
|
| 1099 |
+
);
|
| 1100 |
+
await fetchDeals();
|
| 1101 |
+
} finally {
|
| 1102 |
+
setBulkBusy(false);
|
| 1103 |
+
}
|
| 1104 |
+
};
|
| 1105 |
+
|
| 1106 |
+
const bulkApplyStage = async () => {
|
| 1107 |
+
if (bulkStageValue === BULK_NONE || selectedDealIds.length === 0) return;
|
| 1108 |
+
setBulkBusy(true);
|
| 1109 |
+
try {
|
| 1110 |
+
await Promise.all(
|
| 1111 |
+
selectedDealIds.map((id) =>
|
| 1112 |
+
apiFetch(`/api/deals/${id}`, {
|
| 1113 |
+
method: 'PATCH',
|
| 1114 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1115 |
+
body: JSON.stringify({ stage: bulkStageValue }),
|
| 1116 |
+
}).then(async (res) => {
|
| 1117 |
+
const data = await res.json().catch(() => ({}));
|
| 1118 |
+
if (!res.ok) {
|
| 1119 |
+
throw new Error(
|
| 1120 |
+
typeof data.detail === 'string' ? data.detail : 'Update failed'
|
| 1121 |
+
);
|
| 1122 |
+
}
|
| 1123 |
+
return data;
|
| 1124 |
+
})
|
| 1125 |
+
)
|
| 1126 |
+
);
|
| 1127 |
+
setBulkStageValue(BULK_NONE);
|
| 1128 |
+
await fetchDeals();
|
| 1129 |
+
} catch (e) {
|
| 1130 |
+
console.error(e);
|
| 1131 |
+
alert(e.message || 'Could not change stage');
|
| 1132 |
+
await fetchDeals();
|
| 1133 |
+
} finally {
|
| 1134 |
+
setBulkBusy(false);
|
| 1135 |
+
}
|
| 1136 |
+
};
|
| 1137 |
+
|
| 1138 |
const dealPanelCloseSel =
|
| 1139 |
dealDetail != null ? closeProbabilitySelectValue(dealDetail.close_probability) : '__clear__';
|
| 1140 |
|
| 1141 |
+
const bulkOwnerTriggerLabel = useMemo(() => {
|
| 1142 |
+
if (bulkOwnerValue === BULK_NONE) return 'Assign owner';
|
| 1143 |
+
if (bulkOwnerValue === EMPTY_OWNER_KEY) return 'Unassigned';
|
| 1144 |
+
const m = (tenantMembers || []).find((x) => String(x.user_id) === bulkOwnerValue);
|
| 1145 |
+
return m ? (m.name || m.email || `User ${m.user_id}`).trim() : 'Assign owner';
|
| 1146 |
+
}, [bulkOwnerValue, tenantMembers]);
|
| 1147 |
+
|
| 1148 |
+
const bulkStageTriggerLabel = useMemo(() => {
|
| 1149 |
+
if (bulkStageValue === BULK_NONE) return 'Change stage';
|
| 1150 |
+
return stageMeta(bulkStageValue).label;
|
| 1151 |
+
}, [bulkStageValue]);
|
| 1152 |
+
|
| 1153 |
return (
|
| 1154 |
<AppShell
|
| 1155 |
title="Deals"
|
|
|
|
| 1208 |
sectionCount={total}
|
| 1209 |
sectionOpen={sectionOpen}
|
| 1210 |
onSectionToggle={() => setSectionOpen(!sectionOpen)}
|
| 1211 |
+
tableToolbar={
|
| 1212 |
+
!loading && dealsView !== 'pipeline' && deals.length > 0 ? (
|
| 1213 |
+
<div
|
| 1214 |
+
className="flex flex-wrap items-center gap-2 border-b border-slate-100 bg-slate-50/90 px-4 py-2.5"
|
| 1215 |
+
aria-busy={bulkBusy}
|
| 1216 |
+
>
|
| 1217 |
+
<span className="text-xs font-medium text-slate-600">
|
| 1218 |
+
{selectedDealIds.length > 0
|
| 1219 |
+
? `${selectedDealIds.length} selected`
|
| 1220 |
+
: 'Select rows below'}
|
| 1221 |
+
</span>
|
| 1222 |
+
<Button
|
| 1223 |
+
type="button"
|
| 1224 |
+
variant="outline"
|
| 1225 |
+
size="icon"
|
| 1226 |
+
className="h-8 w-8 shrink-0 text-slate-700"
|
| 1227 |
+
disabled={selectedDealIds.length === 0 || bulkBusy}
|
| 1228 |
+
title="Delete selected deals"
|
| 1229 |
+
onClick={bulkDeleteDeals}
|
| 1230 |
+
>
|
| 1231 |
+
<Trash2 className="h-4 w-4" aria-hidden />
|
| 1232 |
+
<span className="sr-only">Delete selected</span>
|
| 1233 |
+
</Button>
|
| 1234 |
+
{isAdmin ? (
|
| 1235 |
+
<div className="flex flex-wrap items-center gap-1.5 border-l border-slate-200 pl-2">
|
| 1236 |
+
<UserPlus className="h-4 w-4 shrink-0 text-slate-500" aria-hidden />
|
| 1237 |
+
<Select value={bulkOwnerValue} onValueChange={setBulkOwnerValue}>
|
| 1238 |
+
<SelectTrigger
|
| 1239 |
+
className="h-8 w-[min(100%,11rem)] max-w-[13rem] border-slate-200 bg-white text-xs shadow-sm"
|
| 1240 |
+
title="Assign owner"
|
| 1241 |
+
>
|
| 1242 |
+
<span className="truncate text-left">{bulkOwnerTriggerLabel}</span>
|
| 1243 |
+
</SelectTrigger>
|
| 1244 |
+
<SelectContent className="min-w-[10rem]">
|
| 1245 |
+
<SelectItem value={BULK_NONE}>
|
| 1246 |
+
<span className="text-slate-500">Choose owner…</span>
|
| 1247 |
+
</SelectItem>
|
| 1248 |
+
<SelectItem value={EMPTY_OWNER_KEY}>Unassigned</SelectItem>
|
| 1249 |
+
{(tenantMembers || []).map((m) => (
|
| 1250 |
+
<SelectItem key={m.user_id} value={String(m.user_id)}>
|
| 1251 |
+
{(m.name || m.email || `User ${m.user_id}`).trim()}
|
| 1252 |
+
</SelectItem>
|
| 1253 |
+
))}
|
| 1254 |
+
</SelectContent>
|
| 1255 |
+
</Select>
|
| 1256 |
+
<Button
|
| 1257 |
+
type="button"
|
| 1258 |
+
size="sm"
|
| 1259 |
+
variant="secondary"
|
| 1260 |
+
className="h-8"
|
| 1261 |
+
disabled={
|
| 1262 |
+
selectedDealIds.length === 0 ||
|
| 1263 |
+
bulkOwnerValue === BULK_NONE ||
|
| 1264 |
+
bulkBusy
|
| 1265 |
+
}
|
| 1266 |
+
onClick={bulkApplyOwner}
|
| 1267 |
+
>
|
| 1268 |
+
Apply
|
| 1269 |
+
</Button>
|
| 1270 |
+
</div>
|
| 1271 |
+
) : (
|
| 1272 |
+
<div className="flex items-center gap-1 border-l border-slate-200 pl-2">
|
| 1273 |
+
<Button
|
| 1274 |
+
type="button"
|
| 1275 |
+
variant="outline"
|
| 1276 |
+
size="sm"
|
| 1277 |
+
className="h-8 gap-1.5 px-2"
|
| 1278 |
+
disabled={selectedDealIds.length === 0 || bulkBusy}
|
| 1279 |
+
title="Assign yourself as owner on selected deals"
|
| 1280 |
+
onClick={bulkTakeOwnership}
|
| 1281 |
+
>
|
| 1282 |
+
<UserPlus className="h-4 w-4 shrink-0" aria-hidden />
|
| 1283 |
+
<span className="hidden sm:inline">Take ownership</span>
|
| 1284 |
+
</Button>
|
| 1285 |
+
</div>
|
| 1286 |
+
)}
|
| 1287 |
+
<div className="flex flex-wrap items-center gap-1.5 border-l border-slate-200 pl-2">
|
| 1288 |
+
<GitBranch className="h-4 w-4 shrink-0 text-slate-500" aria-hidden />
|
| 1289 |
+
<Select value={bulkStageValue} onValueChange={setBulkStageValue}>
|
| 1290 |
+
<SelectTrigger
|
| 1291 |
+
className="h-8 w-[min(100%,9.5rem)] max-w-[11rem] border-slate-200 bg-white text-xs shadow-sm"
|
| 1292 |
+
title="Change stage"
|
| 1293 |
+
>
|
| 1294 |
+
<span className="truncate text-left">{bulkStageTriggerLabel}</span>
|
| 1295 |
+
</SelectTrigger>
|
| 1296 |
+
<SelectContent className="min-w-[10rem]">
|
| 1297 |
+
<SelectItem value={BULK_NONE}>
|
| 1298 |
+
<span className="text-slate-500">Choose stage…</span>
|
| 1299 |
+
</SelectItem>
|
| 1300 |
+
{STAGES.map((s) => (
|
| 1301 |
+
<SelectItem key={s.value} value={s.value}>
|
| 1302 |
+
<span
|
| 1303 |
+
className={cn(
|
| 1304 |
+
'rounded-full px-2 py-0.5 text-xs font-medium inline-block',
|
| 1305 |
+
s.className
|
| 1306 |
+
)}
|
| 1307 |
+
>
|
| 1308 |
+
{s.label}
|
| 1309 |
+
</span>
|
| 1310 |
+
</SelectItem>
|
| 1311 |
+
))}
|
| 1312 |
+
</SelectContent>
|
| 1313 |
+
</Select>
|
| 1314 |
+
<Button
|
| 1315 |
+
type="button"
|
| 1316 |
+
size="sm"
|
| 1317 |
+
variant="secondary"
|
| 1318 |
+
className="h-8"
|
| 1319 |
+
disabled={
|
| 1320 |
+
selectedDealIds.length === 0 ||
|
| 1321 |
+
bulkStageValue === BULK_NONE ||
|
| 1322 |
+
bulkBusy
|
| 1323 |
+
}
|
| 1324 |
+
onClick={bulkApplyStage}
|
| 1325 |
+
>
|
| 1326 |
+
Apply
|
| 1327 |
+
</Button>
|
| 1328 |
+
</div>
|
| 1329 |
+
<Button
|
| 1330 |
+
type="button"
|
| 1331 |
+
variant="ghost"
|
| 1332 |
+
size="sm"
|
| 1333 |
+
className="ml-auto h-8 text-slate-600"
|
| 1334 |
+
disabled={selectedDealIds.length === 0 || bulkBusy}
|
| 1335 |
+
onClick={clearDealSelection}
|
| 1336 |
+
>
|
| 1337 |
+
Clear selection
|
| 1338 |
+
</Button>
|
| 1339 |
+
</div>
|
| 1340 |
+
) : null
|
| 1341 |
+
}
|
| 1342 |
>
|
| 1343 |
<div className="w-full min-w-0 overflow-x-auto">
|
| 1344 |
{loading ? (
|
|
|
|
| 1378 |
<table className="w-full text-sm min-w-[1120px]">
|
| 1379 |
<thead>
|
| 1380 |
<tr className="bg-slate-50 text-left text-slate-600 border-b border-slate-200">
|
| 1381 |
+
<th className="px-2 py-2 w-10 text-center">
|
| 1382 |
+
<input
|
| 1383 |
+
ref={selectAllCheckboxRef}
|
| 1384 |
+
type="checkbox"
|
| 1385 |
+
className="rounded border-slate-300"
|
| 1386 |
+
checked={
|
| 1387 |
+
deals.length > 0 &&
|
| 1388 |
+
selectedDealIds.length === deals.length &&
|
| 1389 |
+
deals.every((d) => selectedDealIds.includes(d.id))
|
| 1390 |
+
}
|
| 1391 |
+
onChange={toggleSelectAll}
|
| 1392 |
+
aria-label="Select all deals"
|
| 1393 |
+
title="Select all"
|
| 1394 |
+
/>
|
| 1395 |
+
</th>
|
| 1396 |
<th className="px-3 py-2 font-medium min-w-[16rem] max-w-[min(28rem,40vw)]">
|
| 1397 |
Deal
|
| 1398 |
</th>
|
|
|
|
| 1423 |
isAdmin={isAdmin}
|
| 1424 |
currentUserId={currentUserId}
|
| 1425 |
tenantMembers={tenantMembers}
|
| 1426 |
+
selected={selectedDealIds.includes(deal.id)}
|
| 1427 |
+
onToggleSelected={onToggleDealSelected}
|
| 1428 |
/>
|
| 1429 |
))}
|
| 1430 |
</tbody>
|
|
|
|
| 1461 |
isAdmin={isAdmin}
|
| 1462 |
currentUserId={currentUserId}
|
| 1463 |
tenantMembers={tenantMembers}
|
| 1464 |
+
selectedDealIds={selectedDealIds}
|
| 1465 |
+
onToggleDealSelected={onToggleDealSelected}
|
| 1466 |
/>
|
| 1467 |
))
|
| 1468 |
) : dealsView === 'byCountry' ? (
|
|
|
|
| 1487 |
isAdmin={isAdmin}
|
| 1488 |
currentUserId={currentUserId}
|
| 1489 |
tenantMembers={tenantMembers}
|
| 1490 |
+
selectedDealIds={selectedDealIds}
|
| 1491 |
+
onToggleDealSelected={onToggleDealSelected}
|
| 1492 |
/>
|
| 1493 |
))
|
| 1494 |
) : dealsView === 'byOwner' ? (
|
|
|
|
| 1523 |
isAdmin={isAdmin}
|
| 1524 |
currentUserId={currentUserId}
|
| 1525 |
tenantMembers={tenantMembers}
|
| 1526 |
+
selectedDealIds={selectedDealIds}
|
| 1527 |
+
onToggleDealSelected={onToggleDealSelected}
|
| 1528 |
/>
|
| 1529 |
))
|
| 1530 |
) : null}
|