Seth commited on
Commit
bc88140
·
1 Parent(s): 897d4ad
Files changed (2) hide show
  1. backend/app/main.py +16 -0
  2. 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({ deal, patchDeal, updateStage, openDeal, isAdmin, currentUserId, tenantMembers }) {
 
 
 
 
 
 
 
 
 
 
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 type="checkbox" className="rounded border-slate-300" aria-label="Select row" />
 
 
 
 
 
 
 
 
 
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" aria-label="Select row" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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}