Seth commited on
Commit
635c08a
·
1 Parent(s): 16abd4a
frontend/src/components/workspace/SearchableCountryPicker.jsx ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { cn } from '@/lib/utils';
4
+ import { getAllCountries, flagEmojiFromCode, matchCountry } from '@/lib/countries';
5
+
6
+ /**
7
+ * Table cell: shows flag only; opens searchable list of countries (full names).
8
+ */
9
+ export function SearchableCountryPicker({ value, onChange, className = '' }) {
10
+ const countries = useMemo(() => getAllCountries(), []);
11
+ const [open, setOpen] = useState(false);
12
+ const [query, setQuery] = useState('');
13
+ const triggerRef = useRef(null);
14
+ const menuRef = useRef(null);
15
+ const inputRef = useRef(null);
16
+ const [pos, setPos] = useState({ top: 0, left: 0, width: 0 });
17
+
18
+ const matched = matchCountry(value || '', countries);
19
+ const flag = matched ? flagEmojiFromCode(matched.code) : '🏳️';
20
+ const title = matched ? matched.name : value || 'Select country';
21
+
22
+ const filtered = useMemo(() => {
23
+ const q = query.trim().toLowerCase();
24
+ if (!q) return countries;
25
+ return countries.filter(
26
+ (c) =>
27
+ c.name.toLowerCase().includes(q) || c.code.toLowerCase().includes(q)
28
+ );
29
+ }, [countries, query]);
30
+
31
+ useLayoutEffect(() => {
32
+ if (!open || !triggerRef.current) return;
33
+ const r = triggerRef.current.getBoundingClientRect();
34
+ setPos({ top: r.bottom + 4, left: r.left, width: Math.max(r.width, 220) });
35
+ }, [open]);
36
+
37
+ useEffect(() => {
38
+ if (!open) return;
39
+ const reposition = () => {
40
+ if (!triggerRef.current) return;
41
+ const r = triggerRef.current.getBoundingClientRect();
42
+ setPos({ top: r.bottom + 4, left: r.left, width: Math.max(r.width, 220) });
43
+ };
44
+ window.addEventListener('scroll', reposition, true);
45
+ window.addEventListener('resize', reposition);
46
+ return () => {
47
+ window.removeEventListener('scroll', reposition, true);
48
+ window.removeEventListener('resize', reposition);
49
+ };
50
+ }, [open]);
51
+
52
+ useEffect(() => {
53
+ if (!open) return;
54
+ const t = requestAnimationFrame(() => inputRef.current?.focus());
55
+ return () => cancelAnimationFrame(t);
56
+ }, [open]);
57
+
58
+ useEffect(() => {
59
+ if (!open) return;
60
+ const onPointerDown = (e) => {
61
+ const tr = triggerRef.current;
62
+ const menu = menuRef.current;
63
+ if (tr?.contains(e.target) || menu?.contains(e.target)) return;
64
+ setOpen(false);
65
+ setQuery('');
66
+ };
67
+ document.addEventListener('pointerdown', onPointerDown);
68
+ return () => document.removeEventListener('pointerdown', onPointerDown);
69
+ }, [open]);
70
+
71
+ const pick = (row) => {
72
+ onChange(row.name);
73
+ setOpen(false);
74
+ setQuery('');
75
+ };
76
+
77
+ return (
78
+ <div className={cn('relative inline-flex', className)}>
79
+ <button
80
+ ref={triggerRef}
81
+ type="button"
82
+ title={title}
83
+ aria-haspopup="listbox"
84
+ aria-expanded={open}
85
+ onClick={(e) => {
86
+ e.stopPropagation();
87
+ setOpen((o) => !o);
88
+ }}
89
+ className={cn(
90
+ 'flex h-8 min-w-[2.25rem] items-center justify-center rounded-md border border-transparent',
91
+ 'bg-transparent text-xl leading-none hover:border-slate-200 hover:bg-white',
92
+ 'focus:outline-none focus:ring-2 focus:ring-violet-200 focus:ring-offset-0'
93
+ )}
94
+ >
95
+ <span aria-hidden>{flag}</span>
96
+ <span className="sr-only">{title}</span>
97
+ </button>
98
+
99
+ {open &&
100
+ createPortal(
101
+ <div
102
+ ref={menuRef}
103
+ role="listbox"
104
+ data-country-picker="true"
105
+ className="fixed z-[10050] rounded-md border border-slate-200 bg-white shadow-lg"
106
+ style={{
107
+ top: pos.top,
108
+ left: pos.left,
109
+ minWidth: pos.width,
110
+ maxHeight: 'min(70vh, 20rem)',
111
+ display: 'flex',
112
+ flexDirection: 'column',
113
+ }}
114
+ onClick={(e) => e.stopPropagation()}
115
+ >
116
+ <div className="border-b border-slate-100 p-2">
117
+ <input
118
+ ref={inputRef}
119
+ type="search"
120
+ role="combobox"
121
+ aria-autocomplete="list"
122
+ placeholder="Search countries…"
123
+ value={query}
124
+ onChange={(e) => setQuery(e.target.value)}
125
+ onKeyDown={(e) => e.stopPropagation()}
126
+ className="w-full rounded-md border border-slate-200 px-2 py-1.5 text-sm outline-none focus:border-violet-300 focus:ring-1 focus:ring-violet-200"
127
+ />
128
+ </div>
129
+ <div className="overflow-y-auto p-1">
130
+ <button
131
+ type="button"
132
+ role="option"
133
+ className="flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm text-slate-600 hover:bg-slate-100"
134
+ onClick={() => {
135
+ onChange('');
136
+ setOpen(false);
137
+ setQuery('');
138
+ }}
139
+ >
140
+ <span className="text-base" aria-hidden>
141
+ 🏳️
142
+ </span>
143
+ <span>Clear</span>
144
+ </button>
145
+ {filtered.map((c) => (
146
+ <button
147
+ key={c.code}
148
+ type="button"
149
+ role="option"
150
+ className={cn(
151
+ 'flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-slate-100',
152
+ matched?.code === c.code && 'bg-violet-50'
153
+ )}
154
+ onClick={() => pick(c)}
155
+ >
156
+ <span className="text-base leading-none" aria-hidden>
157
+ {flagEmojiFromCode(c.code)}
158
+ </span>
159
+ <span>{c.name}</span>
160
+ </button>
161
+ ))}
162
+ {filtered.length === 0 ? (
163
+ <div className="px-2 py-3 text-center text-sm text-slate-500">
164
+ No matches
165
+ </div>
166
+ ) : null}
167
+ </div>
168
+ </div>,
169
+ document.body
170
+ )}
171
+ </div>
172
+ );
173
+ }
frontend/src/lib/countries.js ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** ISO 3166-1 alpha-2 → regional indicator symbols (flag emoji). */
2
+ export function flagEmojiFromCode(code) {
3
+ if (!code || typeof code !== 'string' || code.length !== 2) return '🏳️';
4
+ const u = code.toUpperCase();
5
+ if (u.length !== 2 || /[^A-Z]/.test(u)) return '🏳️';
6
+ return String.fromCodePoint(...[...u].map((c) => 127397 + c.charCodeAt(0)));
7
+ }
8
+
9
+ const COMMON = [
10
+ ['US', 'United States'],
11
+ ['CA', 'Canada'],
12
+ ['GB', 'United Kingdom'],
13
+ ['AU', 'Australia'],
14
+ ['DE', 'Germany'],
15
+ ['FR', 'France'],
16
+ ['IN', 'India'],
17
+ ['JP', 'Japan'],
18
+ ['CN', 'China'],
19
+ ['BR', 'Brazil'],
20
+ ['MX', 'Mexico'],
21
+ ['ES', 'Spain'],
22
+ ['IT', 'Italy'],
23
+ ['NL', 'Netherlands'],
24
+ ['SE', 'Sweden'],
25
+ ['NO', 'Norway'],
26
+ ['DK', 'Denmark'],
27
+ ['FI', 'Finland'],
28
+ ['CH', 'Switzerland'],
29
+ ['AT', 'Austria'],
30
+ ['BE', 'Belgium'],
31
+ ['IE', 'Ireland'],
32
+ ['NZ', 'New Zealand'],
33
+ ['SG', 'Singapore'],
34
+ ['KR', 'South Korea'],
35
+ ['TW', 'Taiwan'],
36
+ ['HK', 'Hong Kong'],
37
+ ['ZA', 'South Africa'],
38
+ ['AE', 'United Arab Emirates'],
39
+ ['IL', 'Israel'],
40
+ ['PL', 'Poland'],
41
+ ['PT', 'Portugal'],
42
+ ['AR', 'Argentina'],
43
+ ['CL', 'Chile'],
44
+ ['CO', 'Colombia'],
45
+ ];
46
+
47
+ let cached = null;
48
+
49
+ /**
50
+ * @returns {{ code: string, name: string }[]}
51
+ */
52
+ export function getAllCountries() {
53
+ if (cached) return cached;
54
+ if (typeof Intl !== 'undefined' && typeof Intl.supportedValuesOf === 'function') {
55
+ try {
56
+ const codes = Intl.supportedValuesOf('region').filter((c) => c.length === 2);
57
+ const dn = new Intl.DisplayNames(['en'], { type: 'region' });
58
+ const rows = codes
59
+ .map((code) => {
60
+ let name = '';
61
+ try {
62
+ name = dn.of(code) || '';
63
+ } catch {
64
+ name = '';
65
+ }
66
+ return { code, name: name && name !== code ? name : '' };
67
+ })
68
+ .filter((x) => x.name);
69
+ rows.sort((a, b) => a.name.localeCompare(b.name));
70
+ cached = rows;
71
+ return cached;
72
+ } catch {
73
+ /* fallback */
74
+ }
75
+ }
76
+ cached = COMMON.map(([code, name]) => ({ code, name })).sort((a, b) =>
77
+ a.name.localeCompare(b.name)
78
+ );
79
+ return cached;
80
+ }
81
+
82
+ /**
83
+ * Match stored deal.country (full name or code) to a row.
84
+ * @param {string} stored
85
+ * @param {{ code: string, name: string }[]} countries
86
+ */
87
+ export function matchCountry(stored, countries) {
88
+ if (!stored || typeof stored !== 'string') return null;
89
+ const s = stored.trim();
90
+ if (!s) return null;
91
+ const lower = s.toLowerCase();
92
+ const byName = countries.find((c) => c.name.toLowerCase() === lower);
93
+ if (byName) return byName;
94
+ const byCode = countries.find((c) => c.code.toLowerCase() === lower);
95
+ if (byCode) return byCode;
96
+ return countries.find((c) => c.name.toLowerCase().includes(lower)) || null;
97
+ }
frontend/src/pages/Deals.jsx CHANGED
@@ -1,12 +1,13 @@
1
  import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
  import { Link } from 'react-router-dom';
3
- import { Search, Loader2, LayoutGrid, Pencil } 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';
7
  import MainTableWorkspace from '@/components/workspace/MainTableWorkspace';
8
  import SlideOverPanel from '@/components/workspace/SlideOverPanel';
9
  import { EditableCell, EditableDateCell } from '@/components/workspace/EditableCell';
 
10
  import { cn } from '@/lib/utils';
11
 
12
  const STAGES = [
@@ -50,11 +51,19 @@ function fmtDate(iso) {
50
  function isRowUiTarget(e) {
51
  return Boolean(
52
  e.target.closest(
53
- 'input, textarea, button, a, [role="combobox"], [role="listbox"], [data-radix-collection-item]'
54
  )
55
  );
56
  }
57
 
 
 
 
 
 
 
 
 
58
  function focusFirstEditableInRow(tr) {
59
  if (!tr) return;
60
  const el = tr.querySelector('input:not([type="checkbox"]):not([type="hidden"]), textarea');
@@ -71,6 +80,7 @@ function sumNumeric(arr, key) {
71
  function DealRow({ deal, tableEditRowId, setTableEditRowId, patchDeal, updateStage, openDeal }) {
72
  const meta = stageMeta(deal.stage);
73
  const safeStage = STAGES.some((s) => s.value === deal.stage) ? deal.stage : 'new';
 
74
  return (
75
  <tr
76
  role="button"
@@ -233,73 +243,68 @@ function DealRow({ deal, tableEditRowId, setTableEditRowId, patchDeal, updateSta
233
  </div>
234
  )}
235
  </td>
236
- <td className="px-3 py-2 align-top">
237
- {tableEditRowId === deal.id ? (
238
- <EditableDateCell
239
- value={deal.expected_close_date}
240
- onCommit={(dateStr) =>
241
- patchDeal(deal.id, {
242
- expected_close_date: dateStr || null,
243
- })
244
- }
245
- />
246
- ) : (
247
- <div className="min-h-[2rem] py-1 text-sm text-slate-600">
248
- {fmtDate(deal.expected_close_date)}
249
- </div>
250
- )}
251
  </td>
252
- <td className="px-3 py-2 align-top tabular-nums max-w-[90px]">
253
- {tableEditRowId === deal.id ? (
254
- <EditableCell
255
- type="number"
256
- value={
257
- deal.close_probability != null ? String(deal.close_probability) : ''
 
258
  }
259
- onCommit={(v) => {
260
- if (v.trim() === '') {
261
- patchDeal(deal.id, { close_probability: 0 });
262
- return;
263
- }
264
- const n = Math.min(100, Math.max(0, Math.round(Number(v))));
265
- if (!Number.isFinite(n)) return;
266
- patchDeal(deal.id, { close_probability: n });
267
- }}
268
- />
269
- ) : (
270
- <div className="min-h-[2rem] py-1 text-sm text-slate-700 tabular-nums">
271
- {deal.close_probability ?? '—'}%
272
- </div>
273
- )}
 
 
 
 
 
 
 
 
274
  </td>
275
  <td className="px-3 py-2 tabular-nums text-slate-700" title="Forecast = deal value × close %">
276
  {fmtMoney(deal.forecast_value)}
277
  </td>
278
- <td className="px-3 py-2 align-top">
279
- {tableEditRowId === deal.id ? (
280
- <EditableDateCell
281
- value={deal.last_interaction_at}
282
- onCommit={(dateStr) =>
283
- patchDeal(deal.id, {
284
- last_interaction_at: dateStr || null,
285
- })
286
- }
287
- />
288
- ) : (
289
- <div className="min-h-[2rem] py-1 text-sm text-slate-600">
290
- {fmtDate(deal.last_interaction_at)}
291
- </div>
292
- )}
293
  </td>
294
- <td className="px-3 py-2 align-top max-w-[120px]">
295
- {tableEditRowId === deal.id ? (
296
- <EditableCell
297
- value={deal.country || ''}
298
- onCommit={(v) => patchDeal(deal.id, { country: v })}
299
- />
300
- ) : (
301
- <div className="min-h-[2rem] py-1 text-sm text-slate-700">{deal.country || '—'}</div>
302
- )}
303
  </td>
304
  </tr>
305
  );
 
1
  import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
  import { Link } from 'react-router-dom';
3
+ import { Loader2, LayoutGrid, Pencil } 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';
7
  import MainTableWorkspace from '@/components/workspace/MainTableWorkspace';
8
  import SlideOverPanel from '@/components/workspace/SlideOverPanel';
9
  import { EditableCell, EditableDateCell } from '@/components/workspace/EditableCell';
10
+ import { SearchableCountryPicker } from '@/components/workspace/SearchableCountryPicker';
11
  import { cn } from '@/lib/utils';
12
 
13
  const STAGES = [
 
51
  function isRowUiTarget(e) {
52
  return Boolean(
53
  e.target.closest(
54
+ 'input, textarea, button, a, [role="combobox"], [role="listbox"], [data-radix-collection-item], [data-country-picker]'
55
  )
56
  );
57
  }
58
 
59
+ /** Maps stored probability to Select value: __clear__ or "10"…"100" (nearest 10, min 10). */
60
+ function closeProbabilitySelectValue(p) {
61
+ if (p == null || p === '') return '__clear__';
62
+ const n = Number(p);
63
+ if (!Number.isFinite(n) || n <= 0) return '__clear__';
64
+ return String(Math.min(100, Math.max(10, Math.round(n / 10) * 10)));
65
+ }
66
+
67
  function focusFirstEditableInRow(tr) {
68
  if (!tr) return;
69
  const el = tr.querySelector('input:not([type="checkbox"]):not([type="hidden"]), textarea');
 
80
  function DealRow({ deal, tableEditRowId, setTableEditRowId, patchDeal, updateStage, openDeal }) {
81
  const meta = stageMeta(deal.stage);
82
  const safeStage = STAGES.some((s) => s.value === deal.stage) ? deal.stage : 'new';
83
+ const closeSel = closeProbabilitySelectValue(deal.close_probability);
84
  return (
85
  <tr
86
  role="button"
 
243
  </div>
244
  )}
245
  </td>
246
+ <td className="px-3 py-2 align-top" onClick={(e) => e.stopPropagation()}>
247
+ <EditableDateCell
248
+ value={deal.expected_close_date}
249
+ onCommit={(dateStr) =>
250
+ patchDeal(deal.id, {
251
+ expected_close_date: dateStr || null,
252
+ })
253
+ }
254
+ className="text-sm max-w-[10.5rem]"
255
+ />
 
 
 
 
 
256
  </td>
257
+ <td className="px-3 py-2 align-top tabular-nums max-w-[100px]" onClick={(e) => e.stopPropagation()}>
258
+ <Select
259
+ value={closeSel}
260
+ onValueChange={(v) => {
261
+ if (v === '__clear__') {
262
+ patchDeal(deal.id, { close_probability: 0 });
263
+ return;
264
  }
265
+ patchDeal(deal.id, { close_probability: Number(v) });
266
+ }}
267
+ >
268
+ <SelectTrigger
269
+ className={cn(
270
+ 'h-8 w-[min(100%,4.75rem)] border-slate-200 shadow-none justify-center gap-0.5 px-2'
271
+ )}
272
+ >
273
+ <span className="text-sm font-medium tabular-nums text-slate-800">
274
+ {closeSel === '__clear__' ? '—' : `${closeSel}%`}
275
+ </span>
276
+ </SelectTrigger>
277
+ <SelectContent className="min-w-[5.5rem]">
278
+ <SelectItem value="__clear__">
279
+ <span className="text-slate-500">—</span>
280
+ </SelectItem>
281
+ {[10, 20, 30, 40, 50, 60, 70, 80, 90, 100].map((n) => (
282
+ <SelectItem key={n} value={String(n)}>
283
+ <span className="tabular-nums">{n}%</span>
284
+ </SelectItem>
285
+ ))}
286
+ </SelectContent>
287
+ </Select>
288
  </td>
289
  <td className="px-3 py-2 tabular-nums text-slate-700" title="Forecast = deal value × close %">
290
  {fmtMoney(deal.forecast_value)}
291
  </td>
292
+ <td className="px-3 py-2 align-top" onClick={(e) => e.stopPropagation()}>
293
+ <EditableDateCell
294
+ value={deal.last_interaction_at}
295
+ onCommit={(dateStr) =>
296
+ patchDeal(deal.id, {
297
+ last_interaction_at: dateStr || null,
298
+ })
299
+ }
300
+ className="text-sm max-w-[10.5rem]"
301
+ />
 
 
 
 
 
302
  </td>
303
+ <td className="px-3 py-2 align-top max-w-[4rem]" onClick={(e) => e.stopPropagation()}>
304
+ <SearchableCountryPicker
305
+ value={deal.country || ''}
306
+ onChange={(name) => patchDeal(deal.id, { country: name || null })}
307
+ />
 
 
 
 
308
  </td>
309
  </tr>
310
  );