@kahitsan/ksui 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kahitsan/ksui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "ksui is a set of shared SolidJS UI components plus the @kserp/host-ui type contract for KahitSan/Hilinga plugins. Published to the public npm registry and consumed as a normal dependency. Ships source under a `solid` export condition so the consumer's vite-plugin-solid compiles it with solid-js + @kserp/host-ui externalized to the host runtime.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -1,19 +1,15 @@
|
|
|
1
|
-
//
|
|
1
|
+
// ClientPicker — the client preset of EntityPicker. A searchable combobox for a
|
|
2
|
+
// "billed-to" / client field, backed by the sibling clients plugin's
|
|
3
|
+
// /api/clients. All the popup mechanics live in EntityPicker; this file only
|
|
4
|
+
// wires the clients endpoint, option shape (name_raw + email/phone), icon, and
|
|
5
|
+
// the post-create callback.
|
|
2
6
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// the fetch 404s/fails, the popup shows an inline "couldn't load" notice, and
|
|
6
|
-
// the rest of the transaction modal still works (the sale just has no
|
|
7
|
-
// billed-to client). highlightMatch comes from the host UI kit.
|
|
7
|
+
// Public API is unchanged from the standalone version, so existing callers keep
|
|
8
|
+
// working. Degrades gracefully when the clients plugin isn't deployed.
|
|
8
9
|
|
|
9
|
-
import {
|
|
10
|
-
import { createEffect, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js";
|
|
10
|
+
import { type JSX } from "solid-js";
|
|
11
11
|
import UserRound from "lucide-solid/icons/user-round";
|
|
12
|
-
import
|
|
13
|
-
import Search from "lucide-solid/icons/search";
|
|
14
|
-
import X from "lucide-solid/icons/x";
|
|
15
|
-
import Loader2 from "lucide-solid/icons/loader-2";
|
|
16
|
-
import { highlightMatch } from "@kserp/host-ui";
|
|
12
|
+
import EntityPicker from "./EntityPicker";
|
|
17
13
|
|
|
18
14
|
export interface ClientOption {
|
|
19
15
|
id: number;
|
|
@@ -30,329 +26,61 @@ interface ClientPickerProps {
|
|
|
30
26
|
defaultOpen?: boolean;
|
|
31
27
|
}
|
|
32
28
|
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const [results, setResults] = createSignal<ClientOption[]>([]);
|
|
45
|
-
const [loading, setLoading] = createSignal(false);
|
|
46
|
-
const [creating, setCreating] = createSignal(false);
|
|
47
|
-
const [error, setError] = createSignal<string | null>(null);
|
|
48
|
-
const [popupStyle, setPopupStyle] = createSignal<JSX.CSSProperties>({});
|
|
49
|
-
|
|
50
|
-
let triggerRef: HTMLButtonElement | undefined;
|
|
51
|
-
let popupRef: HTMLDivElement | undefined;
|
|
52
|
-
let inputRef: HTMLInputElement | undefined;
|
|
53
|
-
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
54
|
-
let activeFetchToken = 0;
|
|
55
|
-
|
|
56
|
-
createEffect(() => {
|
|
57
|
-
const q = query();
|
|
58
|
-
if (debounceTimer) clearTimeout(debounceTimer);
|
|
59
|
-
debounceTimer = setTimeout(() => setDebouncedQuery(q), SEARCH_DEBOUNCE_MS);
|
|
60
|
-
onCleanup(() => {
|
|
61
|
-
if (debounceTimer) clearTimeout(debounceTimer);
|
|
62
|
-
});
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
createEffect(() => {
|
|
66
|
-
if (!open()) return;
|
|
67
|
-
const q = debouncedQuery().trim();
|
|
68
|
-
const token = ++activeFetchToken;
|
|
69
|
-
setLoading(true);
|
|
70
|
-
setError(null);
|
|
71
|
-
const params = new URLSearchParams({ status: "active", limit: "10" });
|
|
72
|
-
if (q) params.set("search", q);
|
|
73
|
-
fetch(`/api/clients?${params.toString()}`, { credentials: "include" })
|
|
74
|
-
.then((r) => {
|
|
75
|
-
if (!r.ok)
|
|
76
|
-
throw new Error(
|
|
77
|
-
r.status === 403
|
|
78
|
-
? "Permission denied"
|
|
79
|
-
: r.status === 404
|
|
80
|
-
? "Clients module isn't available"
|
|
81
|
-
: "Failed to load",
|
|
82
|
-
);
|
|
83
|
-
return r.json();
|
|
84
|
-
})
|
|
85
|
-
.then((json) => {
|
|
86
|
-
if (token !== activeFetchToken) return;
|
|
87
|
-
setResults((json.data || []) as ClientOption[]);
|
|
88
|
-
})
|
|
89
|
-
.catch((e) => {
|
|
90
|
-
if (token !== activeFetchToken) return;
|
|
91
|
-
setError(e instanceof Error ? e.message : "Failed to load");
|
|
92
|
-
setResults([]);
|
|
93
|
-
})
|
|
94
|
-
.finally(() => {
|
|
95
|
-
if (token !== activeFetchToken) return;
|
|
96
|
-
setLoading(false);
|
|
97
|
-
});
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
const updatePosition = () => {
|
|
101
|
-
if (!triggerRef) return;
|
|
102
|
-
const rect = triggerRef.getBoundingClientRect();
|
|
103
|
-
const vpHeight = window.innerHeight;
|
|
104
|
-
const vpWidth = window.innerWidth;
|
|
105
|
-
const width = Math.max(POPUP_MIN_WIDTH, rect.width);
|
|
106
|
-
const spaceBelow = vpHeight - rect.bottom;
|
|
107
|
-
const spaceAbove = rect.top;
|
|
108
|
-
const flipUp = spaceBelow < POPUP_MAX_HEIGHT && spaceAbove > spaceBelow;
|
|
109
|
-
const top = flipUp ? Math.max(8, rect.top - POPUP_MAX_HEIGHT - 4) : rect.bottom + 4;
|
|
110
|
-
const maxHeight = Math.max(
|
|
111
|
-
200,
|
|
112
|
-
Math.min(POPUP_MAX_HEIGHT, flipUp ? spaceAbove - 12 : spaceBelow - 12),
|
|
29
|
+
async function searchClients(query: string): Promise<ClientOption[]> {
|
|
30
|
+
const params = new URLSearchParams({ status: "active", limit: "10" });
|
|
31
|
+
if (query) params.set("search", query);
|
|
32
|
+
const r = await fetch(`/api/clients?${params.toString()}`, { credentials: "include" });
|
|
33
|
+
if (!r.ok) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
r.status === 403
|
|
36
|
+
? "Permission denied"
|
|
37
|
+
: r.status === 404
|
|
38
|
+
? "Clients module isn't available — type a name instead"
|
|
39
|
+
: "Failed to load",
|
|
113
40
|
);
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
left: `${left}px`,
|
|
119
|
-
width: `${width}px`,
|
|
120
|
-
"max-height": `${maxHeight}px`,
|
|
121
|
-
});
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
createEffect(() => {
|
|
125
|
-
if (!open()) return;
|
|
126
|
-
updatePosition();
|
|
127
|
-
queueMicrotask(() => inputRef?.focus());
|
|
128
|
-
|
|
129
|
-
const onDocClick = (e: MouseEvent) => {
|
|
130
|
-
const t = e.target as Node;
|
|
131
|
-
if (triggerRef?.contains(t)) return;
|
|
132
|
-
if (popupRef?.contains(t)) return;
|
|
133
|
-
close();
|
|
134
|
-
};
|
|
135
|
-
const onEsc = (e: KeyboardEvent) => {
|
|
136
|
-
if (e.key === "Escape") {
|
|
137
|
-
e.stopPropagation();
|
|
138
|
-
close();
|
|
139
|
-
}
|
|
140
|
-
};
|
|
141
|
-
const onReflow = () => updatePosition();
|
|
41
|
+
}
|
|
42
|
+
const json = await r.json();
|
|
43
|
+
return (json.data || []) as ClientOption[];
|
|
44
|
+
}
|
|
142
45
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
document.removeEventListener("keydown", onEsc, true);
|
|
150
|
-
window.removeEventListener("resize", onReflow);
|
|
151
|
-
window.removeEventListener("scroll", onReflow, true);
|
|
152
|
-
});
|
|
46
|
+
async function createClient(name: string): Promise<ClientOption> {
|
|
47
|
+
const res = await fetch("/api/clients", {
|
|
48
|
+
method: "POST",
|
|
49
|
+
credentials: "include",
|
|
50
|
+
headers: { "Content-Type": "application/json" },
|
|
51
|
+
body: JSON.stringify({ name_raw: name }),
|
|
153
52
|
});
|
|
53
|
+
if (!res.ok && res.status !== 200) {
|
|
54
|
+
const body = await res.json().catch(() => ({ error: "Failed to create client" }));
|
|
55
|
+
throw new Error(body.error || "Failed to create client");
|
|
56
|
+
}
|
|
57
|
+
return (await res.json()) as ClientOption;
|
|
58
|
+
}
|
|
154
59
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
setDebouncedQuery("");
|
|
159
|
-
setResults([]);
|
|
160
|
-
setError(null);
|
|
161
|
-
};
|
|
162
|
-
|
|
163
|
-
const trimmedQuery = () => query().trim();
|
|
164
|
-
|
|
165
|
-
const hasExactMatch = () => {
|
|
166
|
-
const q = trimmedQuery().toLowerCase();
|
|
167
|
-
if (!q) return true;
|
|
168
|
-
return results().some((r) => r.name_raw.trim().toLowerCase() === q);
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
const showCreateOption = () => trimmedQuery().length > 0 && !hasExactMatch() && !loading();
|
|
172
|
-
|
|
173
|
-
const select = (c: ClientOption) => {
|
|
174
|
-
props.onChange(c);
|
|
175
|
-
close();
|
|
176
|
-
};
|
|
177
|
-
|
|
178
|
-
const createAndSelect = async () => {
|
|
179
|
-
const name = trimmedQuery();
|
|
180
|
-
if (!name || creating()) return;
|
|
181
|
-
setCreating(true);
|
|
182
|
-
setError(null);
|
|
183
|
-
try {
|
|
184
|
-
const res = await fetch("/api/clients", {
|
|
185
|
-
method: "POST",
|
|
186
|
-
credentials: "include",
|
|
187
|
-
headers: { "Content-Type": "application/json" },
|
|
188
|
-
body: JSON.stringify({ name_raw: name }),
|
|
189
|
-
});
|
|
190
|
-
if (!res.ok) {
|
|
191
|
-
const body = await res.json().catch(() => ({ error: "Failed to create client" }));
|
|
192
|
-
throw new Error(body.error || "Failed to create client");
|
|
193
|
-
}
|
|
194
|
-
const created = (await res.json()) as ClientOption;
|
|
195
|
-
props.onChange(created);
|
|
196
|
-
props.onCreate?.(created);
|
|
197
|
-
close();
|
|
198
|
-
} catch (e) {
|
|
199
|
-
setError(e instanceof Error ? e.message : "Failed to create client");
|
|
200
|
-
} finally {
|
|
201
|
-
setCreating(false);
|
|
202
|
-
}
|
|
203
|
-
};
|
|
204
|
-
|
|
205
|
-
const clear = (e: MouseEvent) => {
|
|
206
|
-
e.stopPropagation();
|
|
207
|
-
props.onChange(null);
|
|
208
|
-
};
|
|
60
|
+
function clientSecondary(c: ClientOption): string | null {
|
|
61
|
+
return [c.email, c.phone].filter(Boolean).join(" · ") || null;
|
|
62
|
+
}
|
|
209
63
|
|
|
64
|
+
export default function ClientPicker(props: ClientPickerProps): JSX.Element {
|
|
210
65
|
return (
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
>
|
|
231
|
-
<span class="flex-1 min-w-0">
|
|
232
|
-
<span class="block truncate text-zinc-100 font-medium">{props.selected!.name_raw}</span>
|
|
233
|
-
<Show when={props.selected!.email || props.selected!.phone}>
|
|
234
|
-
<span class="block truncate text-[11px] text-zinc-500">
|
|
235
|
-
{props.selected!.email || props.selected!.phone}
|
|
236
|
-
</span>
|
|
237
|
-
</Show>
|
|
238
|
-
</span>
|
|
239
|
-
<button
|
|
240
|
-
type="button"
|
|
241
|
-
data-testid="client-picker-clear"
|
|
242
|
-
onClick={clear}
|
|
243
|
-
class="shrink-0 p-1 rounded text-zinc-500 hover:text-red-400 hover:bg-red-500/10 transition-colors cursor-pointer"
|
|
244
|
-
title="Reset to walk-in"
|
|
245
|
-
aria-label="Reset to walk-in"
|
|
246
|
-
>
|
|
247
|
-
<X size={14} />
|
|
248
|
-
</button>
|
|
249
|
-
</Show>
|
|
250
|
-
</button>
|
|
251
|
-
|
|
252
|
-
<Show when={open()}>
|
|
253
|
-
<Portal>
|
|
254
|
-
<div
|
|
255
|
-
ref={popupRef}
|
|
256
|
-
data-testid="client-picker-popup"
|
|
257
|
-
class="z-[100] rounded-md border border-zinc-700 bg-zinc-900/95 backdrop-blur shadow-xl overflow-hidden flex flex-col"
|
|
258
|
-
style={popupStyle()}
|
|
259
|
-
>
|
|
260
|
-
<div class="px-2 py-2 border-b border-zinc-800 flex items-center gap-2">
|
|
261
|
-
<Search size={14} class="text-zinc-500 shrink-0 ml-1" />
|
|
262
|
-
<input
|
|
263
|
-
ref={inputRef}
|
|
264
|
-
type="text"
|
|
265
|
-
data-testid="client-picker-input"
|
|
266
|
-
role="combobox"
|
|
267
|
-
aria-expanded={open()}
|
|
268
|
-
aria-controls="client-picker-listbox"
|
|
269
|
-
aria-autocomplete="list"
|
|
270
|
-
aria-label="Search clients"
|
|
271
|
-
value={query()}
|
|
272
|
-
onInput={(e) => setQuery(e.currentTarget.value)}
|
|
273
|
-
placeholder="Search clients by name, email, phone…"
|
|
274
|
-
class="w-full px-1 py-1 text-sm bg-transparent text-zinc-200 placeholder:text-zinc-600 focus:outline-none"
|
|
275
|
-
/>
|
|
276
|
-
<Show when={loading()}>
|
|
277
|
-
<Loader2 size={14} class="animate-spin text-zinc-500 mr-1 shrink-0" />
|
|
278
|
-
</Show>
|
|
279
|
-
</div>
|
|
280
|
-
<div class="flex-1 overflow-y-auto">
|
|
281
|
-
<Show when={error()}>
|
|
282
|
-
<div role="status" class="px-3 py-2 text-xs text-red-400">
|
|
283
|
-
{error()}
|
|
284
|
-
</div>
|
|
285
|
-
</Show>
|
|
286
|
-
<Show when={!loading() && results().length === 0 && !showCreateOption() && !error()}>
|
|
287
|
-
<div role="status" class="px-3 py-4 text-xs text-zinc-500 text-center">
|
|
288
|
-
{trimmedQuery() ? "No matches" : "Type to search clients…"}
|
|
289
|
-
</div>
|
|
290
|
-
</Show>
|
|
291
|
-
<Show when={results().length > 0}>
|
|
292
|
-
<ul
|
|
293
|
-
id="client-picker-listbox"
|
|
294
|
-
data-testid="client-picker-listbox"
|
|
295
|
-
role="listbox"
|
|
296
|
-
aria-label="Client search results"
|
|
297
|
-
class="m-0 p-0 list-none"
|
|
298
|
-
>
|
|
299
|
-
<For each={results()}>
|
|
300
|
-
{(c) => (
|
|
301
|
-
<li role="option" aria-selected={props.selected?.id === c.id}>
|
|
302
|
-
<button
|
|
303
|
-
type="button"
|
|
304
|
-
data-testid={`client-picker-result-${c.id}`}
|
|
305
|
-
onClick={() => select(c)}
|
|
306
|
-
class="w-full text-left px-3 py-2 hover:bg-amber-500/10 transition-colors flex items-start gap-2 cursor-pointer"
|
|
307
|
-
>
|
|
308
|
-
<UserRound size={14} class="text-zinc-500 shrink-0 mt-0.5" />
|
|
309
|
-
<span class="flex-1 min-w-0">
|
|
310
|
-
<span class="block text-sm text-zinc-100 truncate">
|
|
311
|
-
{highlightMatch(c.name_raw, debouncedQuery().trim())}
|
|
312
|
-
</span>
|
|
313
|
-
<Show when={c.email || c.phone}>
|
|
314
|
-
<span class="block text-[11px] text-zinc-500 truncate">
|
|
315
|
-
{highlightMatch(
|
|
316
|
-
[c.email, c.phone].filter(Boolean).join(" · "),
|
|
317
|
-
debouncedQuery().trim(),
|
|
318
|
-
)}
|
|
319
|
-
</span>
|
|
320
|
-
</Show>
|
|
321
|
-
</span>
|
|
322
|
-
<Show when={props.selected?.id === c.id}>
|
|
323
|
-
<span class="text-amber-400 text-xs shrink-0 mt-0.5">✓</span>
|
|
324
|
-
</Show>
|
|
325
|
-
</button>
|
|
326
|
-
</li>
|
|
327
|
-
)}
|
|
328
|
-
</For>
|
|
329
|
-
</ul>
|
|
330
|
-
</Show>
|
|
331
|
-
<Show when={showCreateOption()}>
|
|
332
|
-
<div class="border-t border-zinc-800">
|
|
333
|
-
<button
|
|
334
|
-
type="button"
|
|
335
|
-
data-testid="client-picker-create"
|
|
336
|
-
onClick={createAndSelect}
|
|
337
|
-
disabled={creating()}
|
|
338
|
-
class="w-full text-left px-3 py-2.5 hover:bg-emerald-500/10 transition-colors flex items-center gap-2 cursor-pointer disabled:cursor-wait disabled:opacity-60"
|
|
339
|
-
>
|
|
340
|
-
<Show
|
|
341
|
-
when={!creating()}
|
|
342
|
-
fallback={<Loader2 size={14} class="animate-spin text-emerald-400 shrink-0" />}
|
|
343
|
-
>
|
|
344
|
-
<UserPlus size={14} class="text-emerald-400 shrink-0" />
|
|
345
|
-
</Show>
|
|
346
|
-
<span class="text-sm text-emerald-300">
|
|
347
|
-
Create "<span class="font-medium">{trimmedQuery()}</span>"
|
|
348
|
-
</span>
|
|
349
|
-
</button>
|
|
350
|
-
</div>
|
|
351
|
-
</Show>
|
|
352
|
-
</div>
|
|
353
|
-
</div>
|
|
354
|
-
</Portal>
|
|
355
|
-
</Show>
|
|
356
|
-
</>
|
|
66
|
+
<EntityPicker<ClientOption>
|
|
67
|
+
selected={props.selected}
|
|
68
|
+
onChange={props.onChange}
|
|
69
|
+
search={searchClients}
|
|
70
|
+
onCreate={async (name) => {
|
|
71
|
+
const created = await createClient(name);
|
|
72
|
+
props.onCreate?.(created);
|
|
73
|
+
return created;
|
|
74
|
+
}}
|
|
75
|
+
idOf={(c) => c.id}
|
|
76
|
+
labelOf={(c) => c.name_raw}
|
|
77
|
+
secondaryOf={clientSecondary}
|
|
78
|
+
icon={UserRound}
|
|
79
|
+
noun="client"
|
|
80
|
+
placeholder="Walk-in"
|
|
81
|
+
disabled={props.disabled}
|
|
82
|
+
defaultOpen={props.defaultOpen}
|
|
83
|
+
testIdPrefix="client-picker"
|
|
84
|
+
/>
|
|
357
85
|
);
|
|
358
86
|
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
// EntityPicker — a generic searchable-combobox for picking one record from a
|
|
2
|
+
// sibling plugin's list (clients, payees, …). It owns the whole interaction:
|
|
3
|
+
// a trigger button, a portal popup with debounced search, keyboard/click-outside
|
|
4
|
+
// dismissal, viewport-aware positioning, an optional inline "create new" row,
|
|
5
|
+
// and graceful degradation when the backing plugin is unreachable.
|
|
6
|
+
//
|
|
7
|
+
// It is DOMAIN-FREE: it knows nothing about clients or payees. The caller wires
|
|
8
|
+
// the data via `search` / `onCreate` and the display via `idOf` / `labelOf` /
|
|
9
|
+
// `secondaryOf` / `icon` / `noun`. Thin presets (ClientPicker, PayeePicker) pass
|
|
10
|
+
// those in, so there is exactly one copy of the popup mechanics.
|
|
11
|
+
//
|
|
12
|
+
// `selectedName` is a free-text fallback shown in the trigger when nothing is
|
|
13
|
+
// picked (e.g. a likely default) — handy when the backing API persists the name
|
|
14
|
+
// as a plain string regardless, so the form still saves if the plugin is absent.
|
|
15
|
+
|
|
16
|
+
import { Portal } from "solid-js/web";
|
|
17
|
+
import { createEffect, createSignal, For, onCleanup, Show, type JSX } from "solid-js";
|
|
18
|
+
import { highlightMatch } from "@kserp/host-ui";
|
|
19
|
+
import UserPlus from "lucide-solid/icons/user-plus";
|
|
20
|
+
import Search from "lucide-solid/icons/search";
|
|
21
|
+
import X from "lucide-solid/icons/x";
|
|
22
|
+
import Loader2 from "lucide-solid/icons/loader-2";
|
|
23
|
+
|
|
24
|
+
export interface EntityPickerProps<T> {
|
|
25
|
+
/** Currently selected record, or null. */
|
|
26
|
+
selected: T | null;
|
|
27
|
+
/** Free-text fallback shown in the trigger when `selected` is null. */
|
|
28
|
+
selectedName?: string | null;
|
|
29
|
+
/** Fired with the chosen record (or null when cleared). */
|
|
30
|
+
onChange: (next: T | null) => void;
|
|
31
|
+
|
|
32
|
+
/** Search the backing list. Receives the trimmed query (may be empty for the
|
|
33
|
+
* initial list). Should resolve to the matching records, or reject so the
|
|
34
|
+
* popup shows the error/fallback. */
|
|
35
|
+
search: (query: string) => Promise<T[]>;
|
|
36
|
+
/** Optional create-new handler. When provided AND the query has no exact
|
|
37
|
+
* match, a "New <noun> …" row appears; resolving selects the created record. */
|
|
38
|
+
onCreate?: (name: string) => Promise<T>;
|
|
39
|
+
|
|
40
|
+
/** Stable identity for selection matching + result keys. */
|
|
41
|
+
idOf: (item: T) => string | number;
|
|
42
|
+
/** Primary display label. */
|
|
43
|
+
labelOf: (item: T) => string;
|
|
44
|
+
/** Optional muted secondary line under the label in results. */
|
|
45
|
+
secondaryOf?: (item: T) => string | null;
|
|
46
|
+
|
|
47
|
+
/** Leading icon component (lucide-solid), e.g. Store / UserRound. */
|
|
48
|
+
icon: (p: { size?: number; class?: string }) => JSX.Element;
|
|
49
|
+
/** Singular noun for UI copy: "payee", "client". */
|
|
50
|
+
noun: string;
|
|
51
|
+
|
|
52
|
+
placeholder?: string;
|
|
53
|
+
disabled?: boolean;
|
|
54
|
+
/** Open the popup immediately on mount. */
|
|
55
|
+
defaultOpen?: boolean;
|
|
56
|
+
/** Prefix for the component's data-testids (default "entity-picker"). */
|
|
57
|
+
testIdPrefix?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const POPUP_MAX_HEIGHT = 360;
|
|
61
|
+
const POPUP_MIN_WIDTH = 320;
|
|
62
|
+
const SEARCH_DEBOUNCE_MS = 200;
|
|
63
|
+
|
|
64
|
+
export default function EntityPicker<T>(props: EntityPickerProps<T>): JSX.Element {
|
|
65
|
+
const [open, setOpen] = createSignal(false);
|
|
66
|
+
const [query, setQuery] = createSignal("");
|
|
67
|
+
const [debouncedQuery, setDebouncedQuery] = createSignal("");
|
|
68
|
+
const [results, setResults] = createSignal<T[]>([]);
|
|
69
|
+
const [loading, setLoading] = createSignal(false);
|
|
70
|
+
const [creating, setCreating] = createSignal(false);
|
|
71
|
+
const [error, setError] = createSignal<string | null>(null);
|
|
72
|
+
const [popupStyle, setPopupStyle] = createSignal<JSX.CSSProperties>({});
|
|
73
|
+
|
|
74
|
+
const tid = (suffix: string) => `${props.testIdPrefix ?? "entity-picker"}-${suffix}`;
|
|
75
|
+
|
|
76
|
+
let triggerRef: HTMLButtonElement | undefined;
|
|
77
|
+
let popupRef: HTMLDivElement | undefined;
|
|
78
|
+
let inputRef: HTMLInputElement | undefined;
|
|
79
|
+
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
80
|
+
let activeFetchToken = 0;
|
|
81
|
+
|
|
82
|
+
if (props.defaultOpen) queueMicrotask(() => setOpen(true));
|
|
83
|
+
|
|
84
|
+
createEffect(() => {
|
|
85
|
+
const q = query();
|
|
86
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
87
|
+
debounceTimer = setTimeout(() => setDebouncedQuery(q), SEARCH_DEBOUNCE_MS);
|
|
88
|
+
onCleanup(() => {
|
|
89
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
createEffect(() => {
|
|
94
|
+
if (!open()) return;
|
|
95
|
+
const q = debouncedQuery().trim();
|
|
96
|
+
const token = ++activeFetchToken;
|
|
97
|
+
setLoading(true);
|
|
98
|
+
setError(null);
|
|
99
|
+
props
|
|
100
|
+
.search(q)
|
|
101
|
+
.then((rows) => {
|
|
102
|
+
if (token !== activeFetchToken) return;
|
|
103
|
+
setResults(rows ?? []);
|
|
104
|
+
})
|
|
105
|
+
.catch((e) => {
|
|
106
|
+
if (token !== activeFetchToken) return;
|
|
107
|
+
setError(e instanceof Error ? e.message : "Failed to load");
|
|
108
|
+
setResults([]);
|
|
109
|
+
})
|
|
110
|
+
.finally(() => {
|
|
111
|
+
if (token !== activeFetchToken) return;
|
|
112
|
+
setLoading(false);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const updatePosition = () => {
|
|
117
|
+
if (!triggerRef) return;
|
|
118
|
+
const rect = triggerRef.getBoundingClientRect();
|
|
119
|
+
const vpHeight = window.innerHeight;
|
|
120
|
+
const vpWidth = window.innerWidth;
|
|
121
|
+
const width = Math.max(POPUP_MIN_WIDTH, rect.width);
|
|
122
|
+
const spaceBelow = vpHeight - rect.bottom;
|
|
123
|
+
const spaceAbove = rect.top;
|
|
124
|
+
const flipUp = spaceBelow < POPUP_MAX_HEIGHT && spaceAbove > spaceBelow;
|
|
125
|
+
const top = flipUp ? Math.max(8, rect.top - POPUP_MAX_HEIGHT - 4) : rect.bottom + 4;
|
|
126
|
+
const maxHeight = Math.max(
|
|
127
|
+
200,
|
|
128
|
+
Math.min(POPUP_MAX_HEIGHT, flipUp ? spaceAbove - 12 : spaceBelow - 12),
|
|
129
|
+
);
|
|
130
|
+
const left = Math.min(Math.max(8, rect.left), vpWidth - width - 8);
|
|
131
|
+
setPopupStyle({
|
|
132
|
+
position: "fixed",
|
|
133
|
+
top: `${top}px`,
|
|
134
|
+
left: `${left}px`,
|
|
135
|
+
width: `${width}px`,
|
|
136
|
+
"max-height": `${maxHeight}px`,
|
|
137
|
+
});
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
createEffect(() => {
|
|
141
|
+
if (!open()) return;
|
|
142
|
+
updatePosition();
|
|
143
|
+
queueMicrotask(() => inputRef?.focus());
|
|
144
|
+
|
|
145
|
+
const onDocClick = (e: MouseEvent) => {
|
|
146
|
+
const t = e.target as Node;
|
|
147
|
+
if (triggerRef?.contains(t)) return;
|
|
148
|
+
if (popupRef?.contains(t)) return;
|
|
149
|
+
close();
|
|
150
|
+
};
|
|
151
|
+
const onEsc = (e: KeyboardEvent) => {
|
|
152
|
+
if (e.key === "Escape") {
|
|
153
|
+
e.stopPropagation();
|
|
154
|
+
close();
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
const onReflow = () => updatePosition();
|
|
158
|
+
|
|
159
|
+
document.addEventListener("mousedown", onDocClick);
|
|
160
|
+
document.addEventListener("keydown", onEsc, true);
|
|
161
|
+
window.addEventListener("resize", onReflow);
|
|
162
|
+
window.addEventListener("scroll", onReflow, true);
|
|
163
|
+
onCleanup(() => {
|
|
164
|
+
document.removeEventListener("mousedown", onDocClick);
|
|
165
|
+
document.removeEventListener("keydown", onEsc, true);
|
|
166
|
+
window.removeEventListener("resize", onReflow);
|
|
167
|
+
window.removeEventListener("scroll", onReflow, true);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const close = () => {
|
|
172
|
+
setOpen(false);
|
|
173
|
+
setQuery("");
|
|
174
|
+
setDebouncedQuery("");
|
|
175
|
+
setResults([]);
|
|
176
|
+
setError(null);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const trimmedQuery = () => query().trim();
|
|
180
|
+
|
|
181
|
+
const hasExactMatch = () => {
|
|
182
|
+
const q = trimmedQuery().toLowerCase();
|
|
183
|
+
if (!q) return true;
|
|
184
|
+
return results().some((r) => props.labelOf(r).trim().toLowerCase() === q);
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const showCreateOption = () =>
|
|
188
|
+
!!props.onCreate && trimmedQuery().length > 0 && !hasExactMatch() && !loading();
|
|
189
|
+
|
|
190
|
+
const select = (item: T) => {
|
|
191
|
+
props.onChange(item);
|
|
192
|
+
close();
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const createAndSelect = async () => {
|
|
196
|
+
const name = trimmedQuery();
|
|
197
|
+
if (!name || creating() || !props.onCreate) return;
|
|
198
|
+
setCreating(true);
|
|
199
|
+
setError(null);
|
|
200
|
+
try {
|
|
201
|
+
const created = await props.onCreate(name);
|
|
202
|
+
props.onChange(created);
|
|
203
|
+
close();
|
|
204
|
+
} catch (e) {
|
|
205
|
+
setError(e instanceof Error ? e.message : `Failed to create ${props.noun}`);
|
|
206
|
+
} finally {
|
|
207
|
+
setCreating(false);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const clear = (e: MouseEvent) => {
|
|
212
|
+
e.stopPropagation();
|
|
213
|
+
props.onChange(null);
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const triggerLabel = () => {
|
|
217
|
+
if (props.selected) return props.labelOf(props.selected);
|
|
218
|
+
if (props.selectedName && props.selectedName.trim()) return props.selectedName.trim();
|
|
219
|
+
return null;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const placeholder = () => props.placeholder ?? `Tap to pick a ${props.noun}`;
|
|
223
|
+
const Icon = props.icon;
|
|
224
|
+
|
|
225
|
+
return (
|
|
226
|
+
<>
|
|
227
|
+
<button
|
|
228
|
+
ref={triggerRef}
|
|
229
|
+
type="button"
|
|
230
|
+
data-testid={tid("trigger")}
|
|
231
|
+
disabled={props.disabled}
|
|
232
|
+
onClick={() => !props.disabled && setOpen((o) => !o)}
|
|
233
|
+
class="w-full flex items-center gap-2 px-3 py-2.5 rounded-lg bg-zinc-800/30 border border-zinc-700/50 hover:border-amber-500/40 hover:bg-amber-500/5 transition-colors text-sm text-left cursor-pointer disabled:cursor-not-allowed disabled:opacity-60"
|
|
234
|
+
aria-haspopup="listbox"
|
|
235
|
+
aria-expanded={open()}
|
|
236
|
+
>
|
|
237
|
+
<Icon size={16} class="shrink-0 text-zinc-400" />
|
|
238
|
+
<Show when={triggerLabel()} fallback={<span class="text-zinc-500 italic">{placeholder()}</span>}>
|
|
239
|
+
<span class="flex-1 min-w-0">
|
|
240
|
+
<span class="block truncate text-zinc-100 font-medium">{triggerLabel()}</span>
|
|
241
|
+
</span>
|
|
242
|
+
<button
|
|
243
|
+
type="button"
|
|
244
|
+
data-testid={tid("clear")}
|
|
245
|
+
onClick={clear}
|
|
246
|
+
class="shrink-0 p-1 rounded text-zinc-500 hover:text-red-400 hover:bg-red-500/10 transition-colors cursor-pointer"
|
|
247
|
+
title="Clear"
|
|
248
|
+
aria-label={`Clear ${props.noun}`}
|
|
249
|
+
>
|
|
250
|
+
<X size={14} />
|
|
251
|
+
</button>
|
|
252
|
+
</Show>
|
|
253
|
+
</button>
|
|
254
|
+
|
|
255
|
+
<Show when={open()}>
|
|
256
|
+
<Portal>
|
|
257
|
+
<div
|
|
258
|
+
ref={popupRef}
|
|
259
|
+
data-testid={tid("popup")}
|
|
260
|
+
class="z-[100] rounded-md border border-zinc-700 bg-zinc-900/95 backdrop-blur shadow-xl overflow-hidden flex flex-col"
|
|
261
|
+
style={popupStyle()}
|
|
262
|
+
>
|
|
263
|
+
<div class="px-2 py-2 border-b border-zinc-800 flex items-center gap-2">
|
|
264
|
+
<Search size={14} class="text-zinc-500 shrink-0 ml-1" />
|
|
265
|
+
<input
|
|
266
|
+
ref={inputRef}
|
|
267
|
+
type="text"
|
|
268
|
+
data-testid={tid("input")}
|
|
269
|
+
role="combobox"
|
|
270
|
+
aria-expanded={open()}
|
|
271
|
+
aria-controls={`${tid("listbox")}`}
|
|
272
|
+
aria-autocomplete="list"
|
|
273
|
+
aria-label={`Search ${props.noun}s`}
|
|
274
|
+
value={query()}
|
|
275
|
+
onInput={(e) => setQuery(e.currentTarget.value)}
|
|
276
|
+
placeholder={props.onCreate ? `Search or add a new ${props.noun}…` : `Search ${props.noun}s…`}
|
|
277
|
+
class="w-full px-1 py-1 text-sm bg-transparent text-zinc-200 placeholder:text-zinc-600 focus:outline-none"
|
|
278
|
+
/>
|
|
279
|
+
<Show when={loading()}>
|
|
280
|
+
<Loader2 size={14} class="animate-spin text-zinc-500 mr-1 shrink-0" />
|
|
281
|
+
</Show>
|
|
282
|
+
</div>
|
|
283
|
+
<div class="flex-1 overflow-y-auto">
|
|
284
|
+
<Show when={error()}>
|
|
285
|
+
<div role="status" class="px-3 py-2 text-xs text-red-400">
|
|
286
|
+
{error()}
|
|
287
|
+
</div>
|
|
288
|
+
</Show>
|
|
289
|
+
<Show when={!loading() && results().length === 0 && !showCreateOption() && !error()}>
|
|
290
|
+
<div role="status" class="px-3 py-4 text-xs text-zinc-500 text-center">
|
|
291
|
+
{trimmedQuery() ? "No matches" : "Start typing or pick from your list…"}
|
|
292
|
+
</div>
|
|
293
|
+
</Show>
|
|
294
|
+
<Show when={results().length > 0}>
|
|
295
|
+
<ul
|
|
296
|
+
id={tid("listbox")}
|
|
297
|
+
data-testid={tid("listbox")}
|
|
298
|
+
role="listbox"
|
|
299
|
+
aria-label={`${props.noun} search results`}
|
|
300
|
+
class="m-0 p-0 list-none"
|
|
301
|
+
>
|
|
302
|
+
<For each={results()}>
|
|
303
|
+
{(item) => {
|
|
304
|
+
const secondary = props.secondaryOf?.(item) ?? null;
|
|
305
|
+
const isSel = () =>
|
|
306
|
+
props.selected != null && props.idOf(props.selected) === props.idOf(item);
|
|
307
|
+
return (
|
|
308
|
+
<li role="option" aria-selected={isSel()}>
|
|
309
|
+
<button
|
|
310
|
+
type="button"
|
|
311
|
+
data-testid={`${tid("result")}-${props.idOf(item)}`}
|
|
312
|
+
onClick={() => select(item)}
|
|
313
|
+
class="w-full text-left px-3 py-2 hover:bg-amber-500/10 transition-colors flex items-start gap-2 cursor-pointer"
|
|
314
|
+
>
|
|
315
|
+
<Icon size={14} class="text-zinc-500 shrink-0 mt-0.5" />
|
|
316
|
+
<span class="flex-1 min-w-0">
|
|
317
|
+
<span class="block text-sm text-zinc-100 truncate">
|
|
318
|
+
{highlightMatch(props.labelOf(item), debouncedQuery().trim())}
|
|
319
|
+
</span>
|
|
320
|
+
<Show when={secondary}>
|
|
321
|
+
<span class="block text-[11px] text-zinc-500 truncate">
|
|
322
|
+
{highlightMatch(secondary!, debouncedQuery().trim())}
|
|
323
|
+
</span>
|
|
324
|
+
</Show>
|
|
325
|
+
</span>
|
|
326
|
+
<Show when={isSel()}>
|
|
327
|
+
<span class="text-amber-400 text-xs shrink-0 mt-0.5">✓</span>
|
|
328
|
+
</Show>
|
|
329
|
+
</button>
|
|
330
|
+
</li>
|
|
331
|
+
);
|
|
332
|
+
}}
|
|
333
|
+
</For>
|
|
334
|
+
</ul>
|
|
335
|
+
</Show>
|
|
336
|
+
<Show when={showCreateOption()}>
|
|
337
|
+
<div class="border-t border-zinc-800">
|
|
338
|
+
<button
|
|
339
|
+
type="button"
|
|
340
|
+
data-testid={tid("create")}
|
|
341
|
+
onClick={createAndSelect}
|
|
342
|
+
disabled={creating()}
|
|
343
|
+
class="w-full text-left px-3 py-2.5 hover:bg-emerald-500/10 transition-colors flex items-center gap-2 cursor-pointer disabled:cursor-wait disabled:opacity-60"
|
|
344
|
+
>
|
|
345
|
+
<Show
|
|
346
|
+
when={!creating()}
|
|
347
|
+
fallback={<Loader2 size={14} class="animate-spin text-emerald-400 shrink-0" />}
|
|
348
|
+
>
|
|
349
|
+
<UserPlus size={14} class="text-emerald-400 shrink-0" />
|
|
350
|
+
</Show>
|
|
351
|
+
<span class="text-sm text-emerald-300">
|
|
352
|
+
New {props.noun} "<span class="font-medium">{trimmedQuery()}</span>"
|
|
353
|
+
</span>
|
|
354
|
+
</button>
|
|
355
|
+
</div>
|
|
356
|
+
</Show>
|
|
357
|
+
</div>
|
|
358
|
+
</div>
|
|
359
|
+
</Portal>
|
|
360
|
+
</Show>
|
|
361
|
+
</>
|
|
362
|
+
);
|
|
363
|
+
}
|
|
@@ -1,21 +1,15 @@
|
|
|
1
|
-
// PayeePicker —
|
|
2
|
-
// "
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// PayeePicker — the payee preset of EntityPicker. A searchable combobox for the
|
|
2
|
+
// "Paid to" / "Received from" / "Payable to" field, backed by the sibling payees
|
|
3
|
+
// plugin's /api/payees. All the popup mechanics live in EntityPicker; this file
|
|
4
|
+
// only wires the payee endpoint, option shape, icon, and create-as-kind.
|
|
5
5
|
//
|
|
6
|
-
//
|
|
7
|
-
// gracefully —
|
|
8
|
-
//
|
|
9
|
-
// trigger usable, and the consuming API can persist payee as a plain string
|
|
10
|
-
// regardless, so the form still saves.
|
|
6
|
+
// Public API is unchanged from the standalone version, so existing callers keep
|
|
7
|
+
// working. Degrades gracefully — a missing payees plugin surfaces a notice and
|
|
8
|
+
// the free-text fallback (selectedName) keeps the trigger usable.
|
|
11
9
|
|
|
12
|
-
import {
|
|
13
|
-
import { createEffect, createSignal, For, onCleanup, Show, type JSX } from "solid-js";
|
|
10
|
+
import { type JSX } from "solid-js";
|
|
14
11
|
import Store from "lucide-solid/icons/store";
|
|
15
|
-
import
|
|
16
|
-
import Search from "lucide-solid/icons/search";
|
|
17
|
-
import X from "lucide-solid/icons/x";
|
|
18
|
-
import Loader2 from "lucide-solid/icons/loader-2";
|
|
12
|
+
import EntityPicker from "./EntityPicker";
|
|
19
13
|
|
|
20
14
|
export type PayeeKind = "vendor" | "customer" | "both";
|
|
21
15
|
|
|
@@ -39,324 +33,59 @@ interface PayeePickerProps {
|
|
|
39
33
|
testIdPrefix?: string;
|
|
40
34
|
}
|
|
41
35
|
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const [popupStyle, setPopupStyle] = createSignal<JSX.CSSProperties>({});
|
|
55
|
-
|
|
56
|
-
const tid = (suffix: string) => `${props.testIdPrefix ?? "payee-picker"}-${suffix}`;
|
|
57
|
-
|
|
58
|
-
let triggerRef: HTMLButtonElement | undefined;
|
|
59
|
-
let popupRef: HTMLDivElement | undefined;
|
|
60
|
-
let inputRef: HTMLInputElement | undefined;
|
|
61
|
-
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
62
|
-
let activeFetchToken = 0;
|
|
63
|
-
|
|
64
|
-
createEffect(() => {
|
|
65
|
-
const q = query();
|
|
66
|
-
if (debounceTimer) clearTimeout(debounceTimer);
|
|
67
|
-
debounceTimer = setTimeout(() => setDebouncedQuery(q), SEARCH_DEBOUNCE_MS);
|
|
68
|
-
onCleanup(() => {
|
|
69
|
-
if (debounceTimer) clearTimeout(debounceTimer);
|
|
70
|
-
});
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
createEffect(() => {
|
|
74
|
-
if (!open()) return;
|
|
75
|
-
const q = debouncedQuery().trim();
|
|
76
|
-
const token = ++activeFetchToken;
|
|
77
|
-
setLoading(true);
|
|
78
|
-
setError(null);
|
|
79
|
-
const params = new URLSearchParams({ status: "active", limit: "20" });
|
|
80
|
-
if (q) params.set("search", q);
|
|
81
|
-
if (props.kind) params.set("kind", props.kind);
|
|
82
|
-
fetch(`/api/payees?${params.toString()}`, { credentials: "include" })
|
|
83
|
-
.then((r) => {
|
|
84
|
-
if (!r.ok)
|
|
85
|
-
throw new Error(
|
|
86
|
-
r.status === 403
|
|
87
|
-
? "Permission denied"
|
|
88
|
-
: r.status === 404
|
|
89
|
-
? "Payees module isn't available — type a name instead"
|
|
90
|
-
: "Failed to load",
|
|
91
|
-
);
|
|
92
|
-
return r.json();
|
|
93
|
-
})
|
|
94
|
-
.then((json) => {
|
|
95
|
-
if (token !== activeFetchToken) return;
|
|
96
|
-
setResults((json.data || []) as PayeeOption[]);
|
|
97
|
-
})
|
|
98
|
-
.catch((e) => {
|
|
99
|
-
if (token !== activeFetchToken) return;
|
|
100
|
-
setError(e instanceof Error ? e.message : "Failed to load");
|
|
101
|
-
setResults([]);
|
|
102
|
-
})
|
|
103
|
-
.finally(() => {
|
|
104
|
-
if (token !== activeFetchToken) return;
|
|
105
|
-
setLoading(false);
|
|
106
|
-
});
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
const updatePosition = () => {
|
|
110
|
-
if (!triggerRef) return;
|
|
111
|
-
const rect = triggerRef.getBoundingClientRect();
|
|
112
|
-
const vpHeight = window.innerHeight;
|
|
113
|
-
const vpWidth = window.innerWidth;
|
|
114
|
-
const width = Math.max(POPUP_MIN_WIDTH, rect.width);
|
|
115
|
-
const spaceBelow = vpHeight - rect.bottom;
|
|
116
|
-
const spaceAbove = rect.top;
|
|
117
|
-
const flipUp = spaceBelow < POPUP_MAX_HEIGHT && spaceAbove > spaceBelow;
|
|
118
|
-
const top = flipUp ? Math.max(8, rect.top - POPUP_MAX_HEIGHT - 4) : rect.bottom + 4;
|
|
119
|
-
const maxHeight = Math.max(
|
|
120
|
-
200,
|
|
121
|
-
Math.min(POPUP_MAX_HEIGHT, flipUp ? spaceAbove - 12 : spaceBelow - 12),
|
|
36
|
+
async function searchPayees(query: string, kind?: PayeeKind): Promise<PayeeOption[]> {
|
|
37
|
+
const params = new URLSearchParams({ status: "active", limit: "20" });
|
|
38
|
+
if (query) params.set("search", query);
|
|
39
|
+
if (kind) params.set("kind", kind);
|
|
40
|
+
const r = await fetch(`/api/payees?${params.toString()}`, { credentials: "include" });
|
|
41
|
+
if (!r.ok) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
r.status === 403
|
|
44
|
+
? "Permission denied"
|
|
45
|
+
: r.status === 404
|
|
46
|
+
? "Payees module isn't available — type a name instead"
|
|
47
|
+
: "Failed to load",
|
|
122
48
|
);
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
left: `${left}px`,
|
|
128
|
-
width: `${width}px`,
|
|
129
|
-
"max-height": `${maxHeight}px`,
|
|
130
|
-
});
|
|
131
|
-
};
|
|
132
|
-
|
|
133
|
-
createEffect(() => {
|
|
134
|
-
if (!open()) return;
|
|
135
|
-
updatePosition();
|
|
136
|
-
queueMicrotask(() => inputRef?.focus());
|
|
137
|
-
|
|
138
|
-
const onDocClick = (e: MouseEvent) => {
|
|
139
|
-
const t = e.target as Node;
|
|
140
|
-
if (triggerRef?.contains(t)) return;
|
|
141
|
-
if (popupRef?.contains(t)) return;
|
|
142
|
-
close();
|
|
143
|
-
};
|
|
144
|
-
const onEsc = (e: KeyboardEvent) => {
|
|
145
|
-
if (e.key === "Escape") {
|
|
146
|
-
e.stopPropagation();
|
|
147
|
-
close();
|
|
148
|
-
}
|
|
149
|
-
};
|
|
150
|
-
const onReflow = () => updatePosition();
|
|
49
|
+
}
|
|
50
|
+
const json = await r.json();
|
|
51
|
+
return (json.data || []) as PayeeOption[];
|
|
52
|
+
}
|
|
151
53
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
document.removeEventListener("keydown", onEsc, true);
|
|
159
|
-
window.removeEventListener("resize", onReflow);
|
|
160
|
-
window.removeEventListener("scroll", onReflow, true);
|
|
161
|
-
});
|
|
54
|
+
async function createPayee(name: string, kind: PayeeKind): Promise<PayeeOption> {
|
|
55
|
+
const res = await fetch("/api/payees", {
|
|
56
|
+
method: "POST",
|
|
57
|
+
credentials: "include",
|
|
58
|
+
headers: { "Content-Type": "application/json" },
|
|
59
|
+
body: JSON.stringify({ name, kind }),
|
|
162
60
|
});
|
|
61
|
+
if (!res.ok && res.status !== 200) {
|
|
62
|
+
const body = await res.json().catch(() => ({ error: "Failed to create payee" }));
|
|
63
|
+
throw new Error(body.error || "Failed to create payee");
|
|
64
|
+
}
|
|
65
|
+
return (await res.json()) as PayeeOption;
|
|
66
|
+
}
|
|
163
67
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
setResults([]);
|
|
169
|
-
setError(null);
|
|
170
|
-
};
|
|
171
|
-
|
|
172
|
-
const trimmedQuery = () => query().trim();
|
|
173
|
-
|
|
174
|
-
const hasExactMatch = () => {
|
|
175
|
-
const q = trimmedQuery().toLowerCase();
|
|
176
|
-
if (!q) return true;
|
|
177
|
-
return results().some((r) => r.name.trim().toLowerCase() === q);
|
|
178
|
-
};
|
|
179
|
-
|
|
180
|
-
const showCreateOption = () => trimmedQuery().length > 0 && !hasExactMatch() && !loading();
|
|
181
|
-
|
|
182
|
-
const select = (p: PayeeOption) => {
|
|
183
|
-
props.onChange(p);
|
|
184
|
-
close();
|
|
185
|
-
};
|
|
186
|
-
|
|
187
|
-
const createAndSelect = async () => {
|
|
188
|
-
const name = trimmedQuery();
|
|
189
|
-
if (!name || creating()) return;
|
|
190
|
-
setCreating(true);
|
|
191
|
-
setError(null);
|
|
192
|
-
try {
|
|
193
|
-
const res = await fetch("/api/payees", {
|
|
194
|
-
method: "POST",
|
|
195
|
-
credentials: "include",
|
|
196
|
-
headers: { "Content-Type": "application/json" },
|
|
197
|
-
body: JSON.stringify({ name, kind: props.createAsKind ?? props.kind ?? "vendor" }),
|
|
198
|
-
});
|
|
199
|
-
if (!res.ok && res.status !== 200) {
|
|
200
|
-
const body = await res.json().catch(() => ({ error: "Failed to create payee" }));
|
|
201
|
-
throw new Error(body.error || "Failed to create payee");
|
|
202
|
-
}
|
|
203
|
-
const created = (await res.json()) as PayeeOption;
|
|
204
|
-
props.onChange(created);
|
|
205
|
-
close();
|
|
206
|
-
} catch (e) {
|
|
207
|
-
setError(e instanceof Error ? e.message : "Failed to create payee");
|
|
208
|
-
} finally {
|
|
209
|
-
setCreating(false);
|
|
210
|
-
}
|
|
211
|
-
};
|
|
212
|
-
|
|
213
|
-
const clear = (e: MouseEvent) => {
|
|
214
|
-
e.stopPropagation();
|
|
215
|
-
props.onChange(null);
|
|
216
|
-
};
|
|
217
|
-
|
|
218
|
-
const triggerLabel = () => {
|
|
219
|
-
if (props.selected) return props.selected.name;
|
|
220
|
-
if (props.selectedName && props.selectedName.trim()) return props.selectedName.trim();
|
|
221
|
-
return null;
|
|
222
|
-
};
|
|
223
|
-
|
|
224
|
-
const placeholder = () => props.placeholder ?? "Tap to pick a payee";
|
|
68
|
+
function payeeSecondary(p: PayeeOption): string | null {
|
|
69
|
+
if (!p.default_subcategory && p.kind === "vendor") return null;
|
|
70
|
+
return [p.kind === "vendor" ? null : p.kind, p.default_subcategory].filter(Boolean).join(" · ") || null;
|
|
71
|
+
}
|
|
225
72
|
|
|
73
|
+
export default function PayeePicker(props: PayeePickerProps): JSX.Element {
|
|
226
74
|
return (
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
<Show when={props.selected && !props.selected.id}>
|
|
243
|
-
<span class="block text-[11px] text-zinc-500">unlinked (legacy)</span>
|
|
244
|
-
</Show>
|
|
245
|
-
</span>
|
|
246
|
-
<button
|
|
247
|
-
type="button"
|
|
248
|
-
data-testid={tid("clear")}
|
|
249
|
-
onClick={clear}
|
|
250
|
-
class="shrink-0 p-1 rounded text-zinc-500 hover:text-red-400 hover:bg-red-500/10 transition-colors cursor-pointer"
|
|
251
|
-
title="Clear"
|
|
252
|
-
aria-label="Clear payee"
|
|
253
|
-
>
|
|
254
|
-
<X size={14} />
|
|
255
|
-
</button>
|
|
256
|
-
</Show>
|
|
257
|
-
</button>
|
|
258
|
-
|
|
259
|
-
<Show when={open()}>
|
|
260
|
-
<Portal>
|
|
261
|
-
<div
|
|
262
|
-
ref={popupRef}
|
|
263
|
-
data-testid={tid("popup")}
|
|
264
|
-
class="z-[100] rounded-md border border-zinc-700 bg-zinc-900/95 backdrop-blur shadow-xl overflow-hidden flex flex-col"
|
|
265
|
-
style={popupStyle()}
|
|
266
|
-
>
|
|
267
|
-
<div class="px-2 py-2 border-b border-zinc-800 flex items-center gap-2">
|
|
268
|
-
<Search size={14} class="text-zinc-500 shrink-0 ml-1" />
|
|
269
|
-
<input
|
|
270
|
-
ref={inputRef}
|
|
271
|
-
type="text"
|
|
272
|
-
data-testid={tid("input")}
|
|
273
|
-
role="combobox"
|
|
274
|
-
aria-expanded={open()}
|
|
275
|
-
aria-controls={`${tid("listbox")}`}
|
|
276
|
-
aria-autocomplete="list"
|
|
277
|
-
aria-label="Search payees"
|
|
278
|
-
value={query()}
|
|
279
|
-
onInput={(e) => setQuery(e.currentTarget.value)}
|
|
280
|
-
placeholder="Search or add a new payee…"
|
|
281
|
-
class="w-full px-1 py-1 text-sm bg-transparent text-zinc-200 placeholder:text-zinc-600 focus:outline-none"
|
|
282
|
-
/>
|
|
283
|
-
<Show when={loading()}>
|
|
284
|
-
<Loader2 size={14} class="animate-spin text-zinc-500 mr-1 shrink-0" />
|
|
285
|
-
</Show>
|
|
286
|
-
</div>
|
|
287
|
-
<div class="flex-1 overflow-y-auto">
|
|
288
|
-
<Show when={error()}>
|
|
289
|
-
<div role="status" class="px-3 py-2 text-xs text-red-400">
|
|
290
|
-
{error()}
|
|
291
|
-
</div>
|
|
292
|
-
</Show>
|
|
293
|
-
<Show when={!loading() && results().length === 0 && !showCreateOption() && !error()}>
|
|
294
|
-
<div role="status" class="px-3 py-4 text-xs text-zinc-500 text-center">
|
|
295
|
-
{trimmedQuery() ? "No matches" : "Start typing or pick from your list…"}
|
|
296
|
-
</div>
|
|
297
|
-
</Show>
|
|
298
|
-
<Show when={results().length > 0}>
|
|
299
|
-
<ul
|
|
300
|
-
id={tid("listbox")}
|
|
301
|
-
data-testid={tid("listbox")}
|
|
302
|
-
role="listbox"
|
|
303
|
-
aria-label="Payee search results"
|
|
304
|
-
class="m-0 p-0 list-none"
|
|
305
|
-
>
|
|
306
|
-
<For each={results()}>
|
|
307
|
-
{(p) => (
|
|
308
|
-
<li role="option" aria-selected={props.selected?.id === p.id}>
|
|
309
|
-
<button
|
|
310
|
-
type="button"
|
|
311
|
-
data-testid={`${tid("result")}-${p.id}`}
|
|
312
|
-
onClick={() => select(p)}
|
|
313
|
-
class="w-full text-left px-3 py-2 hover:bg-amber-500/10 transition-colors flex items-start gap-2 cursor-pointer"
|
|
314
|
-
>
|
|
315
|
-
<Store size={14} class="text-zinc-500 shrink-0 mt-0.5" />
|
|
316
|
-
<span class="flex-1 min-w-0">
|
|
317
|
-
<span class="block text-sm text-zinc-100 truncate">{p.name}</span>
|
|
318
|
-
<Show when={p.default_subcategory || p.kind !== "vendor"}>
|
|
319
|
-
<span class="block text-[11px] text-zinc-500 truncate">
|
|
320
|
-
{[p.kind === "vendor" ? null : p.kind, p.default_subcategory]
|
|
321
|
-
.filter(Boolean)
|
|
322
|
-
.join(" · ")}
|
|
323
|
-
</span>
|
|
324
|
-
</Show>
|
|
325
|
-
</span>
|
|
326
|
-
<Show when={props.selected?.id === p.id}>
|
|
327
|
-
<span class="text-amber-400 text-xs shrink-0 mt-0.5">✓</span>
|
|
328
|
-
</Show>
|
|
329
|
-
</button>
|
|
330
|
-
</li>
|
|
331
|
-
)}
|
|
332
|
-
</For>
|
|
333
|
-
</ul>
|
|
334
|
-
</Show>
|
|
335
|
-
<Show when={showCreateOption()}>
|
|
336
|
-
<div class="border-t border-zinc-800">
|
|
337
|
-
<button
|
|
338
|
-
type="button"
|
|
339
|
-
data-testid={tid("create")}
|
|
340
|
-
onClick={createAndSelect}
|
|
341
|
-
disabled={creating()}
|
|
342
|
-
class="w-full text-left px-3 py-2.5 hover:bg-emerald-500/10 transition-colors flex items-center gap-2 cursor-pointer disabled:cursor-wait disabled:opacity-60"
|
|
343
|
-
>
|
|
344
|
-
<Show
|
|
345
|
-
when={!creating()}
|
|
346
|
-
fallback={<Loader2 size={14} class="animate-spin text-emerald-400 shrink-0" />}
|
|
347
|
-
>
|
|
348
|
-
<UserPlus size={14} class="text-emerald-400 shrink-0" />
|
|
349
|
-
</Show>
|
|
350
|
-
<span class="text-sm text-emerald-300">
|
|
351
|
-
New payee "<span class="font-medium">{trimmedQuery()}</span>"
|
|
352
|
-
</span>
|
|
353
|
-
</button>
|
|
354
|
-
</div>
|
|
355
|
-
</Show>
|
|
356
|
-
</div>
|
|
357
|
-
</div>
|
|
358
|
-
</Portal>
|
|
359
|
-
</Show>
|
|
360
|
-
</>
|
|
75
|
+
<EntityPicker<PayeeOption>
|
|
76
|
+
selected={props.selected}
|
|
77
|
+
selectedName={props.selectedName}
|
|
78
|
+
onChange={props.onChange}
|
|
79
|
+
search={(q) => searchPayees(q, props.kind)}
|
|
80
|
+
onCreate={(name) => createPayee(name, props.createAsKind ?? props.kind ?? "vendor")}
|
|
81
|
+
idOf={(p) => p.id}
|
|
82
|
+
labelOf={(p) => p.name}
|
|
83
|
+
secondaryOf={payeeSecondary}
|
|
84
|
+
icon={Store}
|
|
85
|
+
noun="payee"
|
|
86
|
+
placeholder={props.placeholder}
|
|
87
|
+
disabled={props.disabled}
|
|
88
|
+
testIdPrefix={props.testIdPrefix ?? "payee-picker"}
|
|
89
|
+
/>
|
|
361
90
|
);
|
|
362
91
|
}
|
package/src/index.ts
CHANGED
|
@@ -95,6 +95,11 @@ export type { MentionTextareaProps } from "./components/composite/MentionTextare
|
|
|
95
95
|
export { default as MarkdownNotes } from "./components/composite/MarkdownNotes";
|
|
96
96
|
export type { MarkdownNotesProps } from "./components/composite/MarkdownNotes";
|
|
97
97
|
|
|
98
|
+
// The generic searchable-combobox engine. ClientPicker / PayeePicker are thin
|
|
99
|
+
// presets over it; new pickers can use EntityPicker directly.
|
|
100
|
+
export { default as EntityPicker } from "./components/composite/EntityPicker";
|
|
101
|
+
export type { EntityPickerProps } from "./components/composite/EntityPicker";
|
|
102
|
+
|
|
98
103
|
export { default as ClientPicker } from "./components/composite/ClientPicker";
|
|
99
104
|
export type { ClientOption } from "./components/composite/ClientPicker";
|
|
100
105
|
|