@kahitsan/ksui 0.7.1 → 0.9.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.9.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,41 +1,59 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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.
|
|
5
6
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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` at each call site (e.g. a payee or client
|
|
10
|
+
// picker), 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.
|
|
11
15
|
|
|
12
16
|
import { Portal } from "solid-js/web";
|
|
13
17
|
import { createEffect, createSignal, For, onCleanup, Show, type JSX } from "solid-js";
|
|
14
|
-
import
|
|
18
|
+
import { highlightMatch } from "@kserp/host-ui";
|
|
15
19
|
import UserPlus from "lucide-solid/icons/user-plus";
|
|
16
20
|
import Search from "lucide-solid/icons/search";
|
|
17
21
|
import X from "lucide-solid/icons/x";
|
|
18
22
|
import Loader2 from "lucide-solid/icons/loader-2";
|
|
19
23
|
|
|
20
|
-
export
|
|
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;
|
|
21
31
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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;
|
|
30
51
|
|
|
31
|
-
interface PayeePickerProps {
|
|
32
|
-
selected: PayeeOption | null;
|
|
33
|
-
selectedName?: string | null;
|
|
34
|
-
kind?: PayeeKind;
|
|
35
|
-
createAsKind?: PayeeKind;
|
|
36
52
|
placeholder?: string;
|
|
37
|
-
onChange: (next: PayeeOption | null) => void;
|
|
38
53
|
disabled?: boolean;
|
|
54
|
+
/** Open the popup immediately on mount. */
|
|
55
|
+
defaultOpen?: boolean;
|
|
56
|
+
/** Prefix for the component's data-testids (default "entity-picker"). */
|
|
39
57
|
testIdPrefix?: string;
|
|
40
58
|
}
|
|
41
59
|
|
|
@@ -43,17 +61,17 @@ const POPUP_MAX_HEIGHT = 360;
|
|
|
43
61
|
const POPUP_MIN_WIDTH = 320;
|
|
44
62
|
const SEARCH_DEBOUNCE_MS = 200;
|
|
45
63
|
|
|
46
|
-
export default function
|
|
64
|
+
export default function EntityPicker<T>(props: EntityPickerProps<T>): JSX.Element {
|
|
47
65
|
const [open, setOpen] = createSignal(false);
|
|
48
66
|
const [query, setQuery] = createSignal("");
|
|
49
67
|
const [debouncedQuery, setDebouncedQuery] = createSignal("");
|
|
50
|
-
const [results, setResults] = createSignal<
|
|
68
|
+
const [results, setResults] = createSignal<T[]>([]);
|
|
51
69
|
const [loading, setLoading] = createSignal(false);
|
|
52
70
|
const [creating, setCreating] = createSignal(false);
|
|
53
71
|
const [error, setError] = createSignal<string | null>(null);
|
|
54
72
|
const [popupStyle, setPopupStyle] = createSignal<JSX.CSSProperties>({});
|
|
55
73
|
|
|
56
|
-
const tid = (suffix: string) => `${props.testIdPrefix ?? "
|
|
74
|
+
const tid = (suffix: string) => `${props.testIdPrefix ?? "entity-picker"}-${suffix}`;
|
|
57
75
|
|
|
58
76
|
let triggerRef: HTMLButtonElement | undefined;
|
|
59
77
|
let popupRef: HTMLDivElement | undefined;
|
|
@@ -61,6 +79,8 @@ export default function PayeePicker(props: PayeePickerProps): JSX.Element {
|
|
|
61
79
|
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
62
80
|
let activeFetchToken = 0;
|
|
63
81
|
|
|
82
|
+
if (props.defaultOpen) queueMicrotask(() => setOpen(true));
|
|
83
|
+
|
|
64
84
|
createEffect(() => {
|
|
65
85
|
const q = query();
|
|
66
86
|
if (debounceTimer) clearTimeout(debounceTimer);
|
|
@@ -76,24 +96,11 @@ export default function PayeePicker(props: PayeePickerProps): JSX.Element {
|
|
|
76
96
|
const token = ++activeFetchToken;
|
|
77
97
|
setLoading(true);
|
|
78
98
|
setError(null);
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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) => {
|
|
99
|
+
props
|
|
100
|
+
.search(q)
|
|
101
|
+
.then((rows) => {
|
|
95
102
|
if (token !== activeFetchToken) return;
|
|
96
|
-
setResults(
|
|
103
|
+
setResults(rows ?? []);
|
|
97
104
|
})
|
|
98
105
|
.catch((e) => {
|
|
99
106
|
if (token !== activeFetchToken) return;
|
|
@@ -174,37 +181,28 @@ export default function PayeePicker(props: PayeePickerProps): JSX.Element {
|
|
|
174
181
|
const hasExactMatch = () => {
|
|
175
182
|
const q = trimmedQuery().toLowerCase();
|
|
176
183
|
if (!q) return true;
|
|
177
|
-
return results().some((r) => r.
|
|
184
|
+
return results().some((r) => props.labelOf(r).trim().toLowerCase() === q);
|
|
178
185
|
};
|
|
179
186
|
|
|
180
|
-
const showCreateOption = () =>
|
|
187
|
+
const showCreateOption = () =>
|
|
188
|
+
!!props.onCreate && trimmedQuery().length > 0 && !hasExactMatch() && !loading();
|
|
181
189
|
|
|
182
|
-
const select = (
|
|
183
|
-
props.onChange(
|
|
190
|
+
const select = (item: T) => {
|
|
191
|
+
props.onChange(item);
|
|
184
192
|
close();
|
|
185
193
|
};
|
|
186
194
|
|
|
187
195
|
const createAndSelect = async () => {
|
|
188
196
|
const name = trimmedQuery();
|
|
189
|
-
if (!name || creating()) return;
|
|
197
|
+
if (!name || creating() || !props.onCreate) return;
|
|
190
198
|
setCreating(true);
|
|
191
199
|
setError(null);
|
|
192
200
|
try {
|
|
193
|
-
const
|
|
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;
|
|
201
|
+
const created = await props.onCreate(name);
|
|
204
202
|
props.onChange(created);
|
|
205
203
|
close();
|
|
206
204
|
} catch (e) {
|
|
207
|
-
setError(e instanceof Error ? e.message :
|
|
205
|
+
setError(e instanceof Error ? e.message : `Failed to create ${props.noun}`);
|
|
208
206
|
} finally {
|
|
209
207
|
setCreating(false);
|
|
210
208
|
}
|
|
@@ -216,12 +214,13 @@ export default function PayeePicker(props: PayeePickerProps): JSX.Element {
|
|
|
216
214
|
};
|
|
217
215
|
|
|
218
216
|
const triggerLabel = () => {
|
|
219
|
-
if (props.selected) return props.selected
|
|
217
|
+
if (props.selected) return props.labelOf(props.selected);
|
|
220
218
|
if (props.selectedName && props.selectedName.trim()) return props.selectedName.trim();
|
|
221
219
|
return null;
|
|
222
220
|
};
|
|
223
221
|
|
|
224
|
-
const placeholder = () => props.placeholder ??
|
|
222
|
+
const placeholder = () => props.placeholder ?? `Tap to pick a ${props.noun}`;
|
|
223
|
+
const Icon = props.icon;
|
|
225
224
|
|
|
226
225
|
return (
|
|
227
226
|
<>
|
|
@@ -235,13 +234,10 @@ export default function PayeePicker(props: PayeePickerProps): JSX.Element {
|
|
|
235
234
|
aria-haspopup="listbox"
|
|
236
235
|
aria-expanded={open()}
|
|
237
236
|
>
|
|
238
|
-
<
|
|
237
|
+
<Icon size={16} class="shrink-0 text-zinc-400" />
|
|
239
238
|
<Show when={triggerLabel()} fallback={<span class="text-zinc-500 italic">{placeholder()}</span>}>
|
|
240
239
|
<span class="flex-1 min-w-0">
|
|
241
240
|
<span class="block truncate text-zinc-100 font-medium">{triggerLabel()}</span>
|
|
242
|
-
<Show when={props.selected && !props.selected.id}>
|
|
243
|
-
<span class="block text-[11px] text-zinc-500">unlinked (legacy)</span>
|
|
244
|
-
</Show>
|
|
245
241
|
</span>
|
|
246
242
|
<button
|
|
247
243
|
type="button"
|
|
@@ -249,7 +245,7 @@ export default function PayeePicker(props: PayeePickerProps): JSX.Element {
|
|
|
249
245
|
onClick={clear}
|
|
250
246
|
class="shrink-0 p-1 rounded text-zinc-500 hover:text-red-400 hover:bg-red-500/10 transition-colors cursor-pointer"
|
|
251
247
|
title="Clear"
|
|
252
|
-
aria-label=
|
|
248
|
+
aria-label={`Clear ${props.noun}`}
|
|
253
249
|
>
|
|
254
250
|
<X size={14} />
|
|
255
251
|
</button>
|
|
@@ -274,10 +270,10 @@ export default function PayeePicker(props: PayeePickerProps): JSX.Element {
|
|
|
274
270
|
aria-expanded={open()}
|
|
275
271
|
aria-controls={`${tid("listbox")}`}
|
|
276
272
|
aria-autocomplete="list"
|
|
277
|
-
aria-label=
|
|
273
|
+
aria-label={`Search ${props.noun}s`}
|
|
278
274
|
value={query()}
|
|
279
275
|
onInput={(e) => setQuery(e.currentTarget.value)}
|
|
280
|
-
placeholder=
|
|
276
|
+
placeholder={props.onCreate ? `Search or add a new ${props.noun}…` : `Search ${props.noun}s…`}
|
|
281
277
|
class="w-full px-1 py-1 text-sm bg-transparent text-zinc-200 placeholder:text-zinc-600 focus:outline-none"
|
|
282
278
|
/>
|
|
283
279
|
<Show when={loading()}>
|
|
@@ -300,35 +296,40 @@ export default function PayeePicker(props: PayeePickerProps): JSX.Element {
|
|
|
300
296
|
id={tid("listbox")}
|
|
301
297
|
data-testid={tid("listbox")}
|
|
302
298
|
role="listbox"
|
|
303
|
-
aria-label=
|
|
299
|
+
aria-label={`${props.noun} search results`}
|
|
304
300
|
class="m-0 p-0 list-none"
|
|
305
301
|
>
|
|
306
302
|
<For each={results()}>
|
|
307
|
-
{(
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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())}
|
|
323
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>
|
|
324
328
|
</Show>
|
|
325
|
-
</
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
</button>
|
|
330
|
-
</li>
|
|
331
|
-
)}
|
|
329
|
+
</button>
|
|
330
|
+
</li>
|
|
331
|
+
);
|
|
332
|
+
}}
|
|
332
333
|
</For>
|
|
333
334
|
</ul>
|
|
334
335
|
</Show>
|
|
@@ -348,7 +349,7 @@ export default function PayeePicker(props: PayeePickerProps): JSX.Element {
|
|
|
348
349
|
<UserPlus size={14} class="text-emerald-400 shrink-0" />
|
|
349
350
|
</Show>
|
|
350
351
|
<span class="text-sm text-emerald-300">
|
|
351
|
-
New
|
|
352
|
+
New {props.noun} "<span class="font-medium">{trimmedQuery()}</span>"
|
|
352
353
|
</span>
|
|
353
354
|
</button>
|
|
354
355
|
</div>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Domain option shapes shared across the KahitSan plugins.
|
|
2
|
+
//
|
|
3
|
+
// These previously lived inside the ClientPicker / PayeePicker preset
|
|
4
|
+
// components. Those presets were removed in favour of using the generic
|
|
5
|
+
// EntityPicker directly (consumers wire their own search/onCreate), but the
|
|
6
|
+
// option TYPES are still imported widely (transactions, counter, payees), so
|
|
7
|
+
// they keep a stable home here decoupled from any component.
|
|
8
|
+
|
|
9
|
+
export type PayeeKind = "vendor" | "customer" | "both";
|
|
10
|
+
|
|
11
|
+
export interface PayeeOption {
|
|
12
|
+
id: number;
|
|
13
|
+
name: string;
|
|
14
|
+
kind: PayeeKind;
|
|
15
|
+
default_subcategory?: string | null;
|
|
16
|
+
notes?: string | null;
|
|
17
|
+
is_active?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ClientOption {
|
|
21
|
+
id: number;
|
|
22
|
+
name_raw: string;
|
|
23
|
+
email?: string | null;
|
|
24
|
+
phone?: string | null;
|
|
25
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -95,11 +95,16 @@ 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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
export
|
|
98
|
+
// The generic searchable-combobox engine — the ONE picker the library ships.
|
|
99
|
+
// Build a payee / client / anything picker by supplying search + onCreate +
|
|
100
|
+
// idOf/labelOf/secondaryOf/icon/noun. (The former ClientPicker / PayeePicker
|
|
101
|
+
// presets were removed; consumers wire the endpoint themselves.)
|
|
102
|
+
export { default as EntityPicker } from "./components/composite/EntityPicker";
|
|
103
|
+
export type { EntityPickerProps } from "./components/composite/EntityPicker";
|
|
104
|
+
|
|
105
|
+
// Shared domain option shapes for the common pickers, decoupled from any
|
|
106
|
+
// component (still imported across transactions / counter / payees).
|
|
107
|
+
export type { ClientOption, PayeeOption, PayeeKind } from "./components/composite/picker-types";
|
|
103
108
|
|
|
104
109
|
export { default as VoucherPicker, calculateDiscount } from "./components/composite/VoucherPicker";
|
|
105
110
|
export type { VoucherOption } from "./components/composite/VoucherPicker";
|
|
@@ -1,358 +0,0 @@
|
|
|
1
|
-
// Source: KahitSan/kserp src/components/ClientPicker.tsx (vendored into the plugin remote).
|
|
2
|
-
//
|
|
3
|
-
// Cross-plugin picker: fetches the SIBLING clients plugin's public API at
|
|
4
|
-
// /api/clients. Degrades gracefully: when the clients plugin isn't deployed
|
|
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.
|
|
8
|
-
|
|
9
|
-
import { Portal } from "solid-js/web";
|
|
10
|
-
import { createEffect, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js";
|
|
11
|
-
import UserRound from "lucide-solid/icons/user-round";
|
|
12
|
-
import UserPlus from "lucide-solid/icons/user-plus";
|
|
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";
|
|
17
|
-
|
|
18
|
-
export interface ClientOption {
|
|
19
|
-
id: number;
|
|
20
|
-
name_raw: string;
|
|
21
|
-
email?: string | null;
|
|
22
|
-
phone?: string | null;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
interface ClientPickerProps {
|
|
26
|
-
selected: ClientOption | null;
|
|
27
|
-
onChange: (next: ClientOption | null) => void;
|
|
28
|
-
onCreate?: (created: ClientOption) => void;
|
|
29
|
-
disabled?: boolean;
|
|
30
|
-
defaultOpen?: boolean;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const POPUP_MAX_HEIGHT = 360;
|
|
34
|
-
const POPUP_MIN_WIDTH = 320;
|
|
35
|
-
const SEARCH_DEBOUNCE_MS = 200;
|
|
36
|
-
|
|
37
|
-
export default function ClientPicker(props: ClientPickerProps): JSX.Element {
|
|
38
|
-
const [open, setOpen] = createSignal(false);
|
|
39
|
-
onMount(() => {
|
|
40
|
-
if (props.defaultOpen) queueMicrotask(() => setOpen(true));
|
|
41
|
-
});
|
|
42
|
-
const [query, setQuery] = createSignal("");
|
|
43
|
-
const [debouncedQuery, setDebouncedQuery] = createSignal("");
|
|
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),
|
|
113
|
-
);
|
|
114
|
-
const left = Math.min(Math.max(8, rect.left), vpWidth - width - 8);
|
|
115
|
-
setPopupStyle({
|
|
116
|
-
position: "fixed",
|
|
117
|
-
top: `${top}px`,
|
|
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();
|
|
142
|
-
|
|
143
|
-
document.addEventListener("mousedown", onDocClick);
|
|
144
|
-
document.addEventListener("keydown", onEsc, true);
|
|
145
|
-
window.addEventListener("resize", onReflow);
|
|
146
|
-
window.addEventListener("scroll", onReflow, true);
|
|
147
|
-
onCleanup(() => {
|
|
148
|
-
document.removeEventListener("mousedown", onDocClick);
|
|
149
|
-
document.removeEventListener("keydown", onEsc, true);
|
|
150
|
-
window.removeEventListener("resize", onReflow);
|
|
151
|
-
window.removeEventListener("scroll", onReflow, true);
|
|
152
|
-
});
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
const close = () => {
|
|
156
|
-
setOpen(false);
|
|
157
|
-
setQuery("");
|
|
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
|
-
};
|
|
209
|
-
|
|
210
|
-
return (
|
|
211
|
-
<>
|
|
212
|
-
<button
|
|
213
|
-
ref={triggerRef}
|
|
214
|
-
type="button"
|
|
215
|
-
data-testid="client-picker-trigger"
|
|
216
|
-
disabled={props.disabled}
|
|
217
|
-
onClick={() => !props.disabled && setOpen((o) => !o)}
|
|
218
|
-
class={`w-full mt-1.5 flex items-center gap-2 px-3 py-2.5 rounded-lg border transition-colors text-sm text-left cursor-pointer disabled:cursor-not-allowed disabled:opacity-60 ${
|
|
219
|
-
props.selected
|
|
220
|
-
? "bg-zinc-800/30 border-zinc-700/50 hover:border-amber-500/40 hover:bg-amber-500/5"
|
|
221
|
-
: "bg-red-500/5 border-red-500/40 hover:bg-red-500/10 hover:border-red-500/60"
|
|
222
|
-
}`}
|
|
223
|
-
aria-haspopup="listbox"
|
|
224
|
-
aria-expanded={open()}
|
|
225
|
-
>
|
|
226
|
-
<UserRound size={16} class={`shrink-0 ${props.selected ? "text-zinc-400" : "text-red-300"}`} />
|
|
227
|
-
<Show
|
|
228
|
-
when={props.selected}
|
|
229
|
-
fallback={<span class="text-red-300/90 italic">Walk-in (tap to pick a client)</span>}
|
|
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
|
-
</>
|
|
357
|
-
);
|
|
358
|
-
}
|