@kahitsan/ksui 0.31.0 → 0.32.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.
@@ -0,0 +1,418 @@
1
+ // Embedded "Packages availed" editor for the Record Transaction modal. Uses
2
+ // ComboBox + VoucherPicker (both degrade gracefully if their endpoints are
3
+ // absent). Fetches the host app's /api/packages — when it 404s/fails the
4
+ // picker shows an inline notice and the sale can still be recorded with a
5
+ // manual amount (the parent hides the package cart but keeps the amount
6
+ // field). The outer form owns destination_account_id / payee / dates; this
7
+ // component owns items[] + client / voucher / manual-discount.
8
+
9
+ import {
10
+ createSignal,
11
+ createMemo,
12
+ createEffect,
13
+ For,
14
+ Show,
15
+ onMount,
16
+ } from "solid-js";
17
+ import ComboBox from "./ComboBox";
18
+ import type { ClientOption } from "./picker-types";
19
+ import VoucherPicker, { calculateDiscount } from "./VoucherPicker";
20
+ import type { VoucherOption } from "./VoucherPicker";
21
+ import UserRound from "lucide-solid/icons/user-round";
22
+ import Plus from "lucide-solid/icons/plus";
23
+
24
+ // Client data-wiring for the generic ComboBox engine. Search/create hit the
25
+ // host app's /api/clients endpoint directly. Degrades gracefully when that
26
+ // endpoint isn't available.
27
+ async function searchClients(query: string): Promise<ClientOption[]> {
28
+ const params = new URLSearchParams({ status: "active", limit: "10" });
29
+ if (query) params.set("search", query);
30
+ const r = await fetch(`/api/clients?${params.toString()}`, {
31
+ credentials: "include",
32
+ });
33
+ if (!r.ok) {
34
+ if (r.status === 403) throw new Error("Permission denied");
35
+ if (r.status === 404)
36
+ throw new Error("Clients module isn't available — type a name instead");
37
+ throw new Error("Failed to load");
38
+ }
39
+ const json = (await r.json()) as { data?: ClientOption[] };
40
+ return json.data ?? [];
41
+ }
42
+
43
+ async function createClient(name: string): Promise<ClientOption> {
44
+ const res = await fetch("/api/clients", {
45
+ method: "POST",
46
+ credentials: "include",
47
+ headers: { "Content-Type": "application/json" },
48
+ body: JSON.stringify({ name_raw: name }),
49
+ });
50
+ if (!res.ok && res.status !== 200) {
51
+ const body = (await res
52
+ .json()
53
+ .catch(() => ({ error: "Failed to create client" }))) as {
54
+ error?: string;
55
+ };
56
+ throw new Error(body.error || "Failed to create client");
57
+ }
58
+ return (await res.json()) as ClientOption;
59
+ }
60
+
61
+ function clientSecondary(c: ClientOption): string | null {
62
+ return [c.email, c.phone].filter(Boolean).join(" · ") || null;
63
+ }
64
+ import Minus from "lucide-solid/icons/minus";
65
+ import Trash2 from "lucide-solid/icons/trash-2";
66
+ import PackageIcon from "lucide-solid/icons/package";
67
+
68
+ interface Variant {
69
+ id: number;
70
+ package_id: number;
71
+ name: string;
72
+ kind: "standard" | "extension" | "bundle";
73
+ duration_value: string | number;
74
+ duration_unit: "hour" | "day" | "month";
75
+ price: string | number;
76
+ currency: string;
77
+ is_active: boolean;
78
+ sort_order: number;
79
+ }
80
+
81
+ interface Package {
82
+ id: number;
83
+ name: string;
84
+ description: string | null;
85
+ type: string;
86
+ is_active: boolean;
87
+ variants: Variant[];
88
+ }
89
+
90
+ export interface SalesLine {
91
+ key: string; // "pkg_id:variant_id"
92
+ package_id: number;
93
+ package_name: string;
94
+ variant_id: number;
95
+ variant_name: string;
96
+ duration_value: number;
97
+ duration_unit: "hour" | "day" | "month";
98
+ unit_price: number;
99
+ quantity: number;
100
+ }
101
+
102
+ export interface SalesBodyEditorProps {
103
+ items: SalesLine[];
104
+ setItems: (next: SalesLine[]) => void;
105
+ client: ClientOption | null;
106
+ setClient: (next: ClientOption | null) => void;
107
+ voucher: VoucherOption | null;
108
+ setVoucher: (next: VoucherOption | null) => void;
109
+ manualDiscount: string;
110
+ setManualDiscount: (next: string) => void;
111
+ }
112
+
113
+ function formatPHP(amount: number): string {
114
+ return new Intl.NumberFormat("en-PH", {
115
+ style: "currency",
116
+ currency: "PHP",
117
+ }).format(amount);
118
+ }
119
+
120
+ export default function SalesBodyEditor(props: SalesBodyEditorProps) {
121
+ const [packages, setPackages] = createSignal<Package[]>([]);
122
+ const [loading, setLoading] = createSignal(true);
123
+ const [loadError, setLoadError] = createSignal<string | null>(null);
124
+ const [pickerOpen, setPickerOpen] = createSignal(false);
125
+
126
+ onMount(async () => {
127
+ try {
128
+ const res = await fetch("/api/packages", { credentials: "include" });
129
+ if (!res.ok)
130
+ throw new Error(
131
+ res.status === 404
132
+ ? "Packages module isn't available"
133
+ : "Failed to load packages"
134
+ );
135
+ const json = await res.json();
136
+ setPackages((json.data || []).filter((p: Package) => p.is_active));
137
+ } catch (e) {
138
+ setLoadError(e instanceof Error ? e.message : "Failed to load packages");
139
+ } finally {
140
+ setLoading(false);
141
+ }
142
+ });
143
+
144
+ function addVariant(pkg: Package, variant: Variant) {
145
+ const key = `${pkg.id}:${variant.id}`;
146
+ const existing = props.items.find((l) => l.key === key);
147
+ if (existing) {
148
+ props.setItems(
149
+ props.items.map((l) =>
150
+ l.key === key ? { ...l, quantity: l.quantity + 1 } : l
151
+ )
152
+ );
153
+ } else {
154
+ const unitPrice =
155
+ typeof variant.price === "string"
156
+ ? parseFloat(variant.price)
157
+ : Number(variant.price);
158
+ const dur =
159
+ typeof variant.duration_value === "string"
160
+ ? parseFloat(variant.duration_value)
161
+ : Number(variant.duration_value);
162
+ props.setItems([
163
+ ...props.items,
164
+ {
165
+ key,
166
+ package_id: pkg.id,
167
+ package_name: pkg.name,
168
+ variant_id: variant.id,
169
+ variant_name: variant.name,
170
+ duration_value: dur,
171
+ duration_unit: variant.duration_unit,
172
+ unit_price: unitPrice,
173
+ quantity: 1,
174
+ },
175
+ ]);
176
+ }
177
+ setPickerOpen(false);
178
+ }
179
+
180
+ function adjust(key: string, delta: number) {
181
+ props.setItems(
182
+ props.items
183
+ .map((l) =>
184
+ l.key === key ? { ...l, quantity: l.quantity + delta } : l
185
+ )
186
+ .filter((l) => l.quantity > 0)
187
+ );
188
+ }
189
+
190
+ function remove(key: string) {
191
+ props.setItems(props.items.filter((l) => l.key !== key));
192
+ }
193
+
194
+ const subtotal = createMemo(() =>
195
+ props.items.reduce((s, l) => s + l.unit_price * l.quantity, 0)
196
+ );
197
+ const voucherDiscount = createMemo(() =>
198
+ calculateDiscount(props.voucher, subtotal())
199
+ );
200
+ const manualDiscountNumber = createMemo(() => {
201
+ const n = parseFloat(props.manualDiscount);
202
+ return Number.isFinite(n) && n > 0 ? n : 0;
203
+ });
204
+ const effectiveDiscount = createMemo(() =>
205
+ props.voucher ? voucherDiscount() : manualDiscountNumber()
206
+ );
207
+ const total = createMemo(() => Math.max(0, subtotal() - effectiveDiscount()));
208
+ const cartPackageIds = createMemo(() => props.items.map((l) => l.package_id));
209
+
210
+ createEffect(() => {
211
+ const v = props.voucher;
212
+ if (!v) return;
213
+ const sub = subtotal();
214
+ const minOk = sub >= Number(v.minimum_purchase ?? 0);
215
+ const allowed = v.applicable_packages;
216
+ const cartIds = cartPackageIds();
217
+ const pkgsOk =
218
+ !allowed ||
219
+ allowed.length === 0 ||
220
+ (cartIds.length > 0 && cartIds.every((id) => allowed.includes(id)));
221
+ if (!minOk || !pkgsOk) props.setVoucher(null);
222
+ });
223
+
224
+ return (
225
+ <div class="rounded-lg border border-emerald-500/20 bg-emerald-500/5 p-3 space-y-3">
226
+ <div class="flex items-center justify-between gap-2 text-[10px] uppercase tracking-widest text-emerald-400 font-semibold">
227
+ <span class="flex items-center gap-1.5">
228
+ <PackageIcon size={12} />
229
+ Packages availed
230
+ </span>
231
+ <button
232
+ type="button"
233
+ onClick={() => setPickerOpen((v) => !v)}
234
+ class="ks-interactive inline-flex items-center gap-1 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-2 py-0.5 text-[10px] uppercase tracking-widest text-emerald-300 hover:bg-emerald-500/20"
235
+ >
236
+ <Plus size={10} /> Add package
237
+ </button>
238
+ </div>
239
+
240
+ <Show when={pickerOpen()}>
241
+ <Show
242
+ when={!loading()}
243
+ fallback={<div class="text-xs text-zinc-500">Loading packages…</div>}
244
+ >
245
+ <Show
246
+ when={!loadError()}
247
+ fallback={<div class="text-xs text-rose-400">{loadError()}</div>}
248
+ >
249
+ <Show
250
+ when={packages().length > 0}
251
+ fallback={
252
+ <div class="text-xs text-zinc-500">No active packages.</div>
253
+ }
254
+ >
255
+ <div class="space-y-1.5 max-h-56 overflow-y-auto rounded-md border border-emerald-500/15 p-2 bg-zinc-950/40">
256
+ <For each={packages()}>
257
+ {(pkg) => (
258
+ <div class="space-y-1">
259
+ <div class="text-[11px] uppercase tracking-widest text-zinc-400">
260
+ {pkg.name}
261
+ </div>
262
+ <div class="flex flex-wrap gap-1.5">
263
+ <For each={pkg.variants.filter((v) => v.is_active)}>
264
+ {(v) => (
265
+ <button
266
+ type="button"
267
+ onClick={() => addVariant(pkg, v)}
268
+ class="ks-interactive inline-flex items-center gap-1 rounded border border-zinc-700 bg-zinc-900 px-2 py-0.5 text-xs text-zinc-300 hover:border-emerald-500/40 hover:text-emerald-300"
269
+ >
270
+ {v.name} · {formatPHP(Number(v.price))}
271
+ </button>
272
+ )}
273
+ </For>
274
+ </div>
275
+ </div>
276
+ )}
277
+ </For>
278
+ </div>
279
+ </Show>
280
+ </Show>
281
+ </Show>
282
+ </Show>
283
+
284
+ <Show
285
+ when={props.items.length > 0}
286
+ fallback={
287
+ <div class="text-xs text-zinc-500">No packages added yet.</div>
288
+ }
289
+ >
290
+ <div class="space-y-1.5">
291
+ <For each={props.items}>
292
+ {(line) => (
293
+ <div class="flex items-center gap-2 text-sm">
294
+ <div class="min-w-0 flex-1">
295
+ <div class="text-zinc-200 truncate">
296
+ {line.package_name}{" "}
297
+ <span class="text-zinc-500">· {line.variant_name}</span>
298
+ </div>
299
+ <div class="text-[11px] text-zinc-500 tabular-nums">
300
+ {formatPHP(line.unit_price)} · {line.duration_value}{" "}
301
+ {line.duration_unit}
302
+ {line.duration_value !== 1 ? "s" : ""}
303
+ </div>
304
+ </div>
305
+ <div class="flex items-center gap-1">
306
+ <button
307
+ type="button"
308
+ onClick={() => adjust(line.key, -1)}
309
+ class="ks-interactive flex h-6 w-6 items-center justify-center rounded border border-zinc-700 bg-zinc-900 text-zinc-300 hover:border-emerald-500/40"
310
+ aria-label="Decrease quantity"
311
+ >
312
+ <Minus size={12} />
313
+ </button>
314
+ <span class="w-6 text-center text-sm text-zinc-200 tabular-nums">
315
+ {line.quantity}
316
+ </span>
317
+ <button
318
+ type="button"
319
+ onClick={() => adjust(line.key, 1)}
320
+ class="ks-interactive flex h-6 w-6 items-center justify-center rounded border border-zinc-700 bg-zinc-900 text-zinc-300 hover:border-emerald-500/40"
321
+ aria-label="Increase quantity"
322
+ >
323
+ <Plus size={12} />
324
+ </button>
325
+ </div>
326
+ <div class="w-20 text-right text-zinc-300 tabular-nums whitespace-nowrap">
327
+ {formatPHP(line.unit_price * line.quantity)}
328
+ </div>
329
+ <button
330
+ type="button"
331
+ onClick={() => remove(line.key)}
332
+ class="ks-interactive flex h-6 w-6 items-center justify-center rounded text-zinc-500 hover:text-rose-400"
333
+ aria-label="Remove line"
334
+ >
335
+ <Trash2 size={12} />
336
+ </button>
337
+ </div>
338
+ )}
339
+ </For>
340
+ </div>
341
+ </Show>
342
+
343
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
344
+ <div>
345
+ <label class="text-[10px] uppercase tracking-widest text-zinc-500 font-semibold">
346
+ Billed to
347
+ </label>
348
+ <ComboBox<ClientOption>
349
+ selected={props.client}
350
+ onChange={(c) => props.setClient(c)}
351
+ search={searchClients}
352
+ onCreate={createClient}
353
+ idOf={(c) => c.id}
354
+ labelOf={(c) => c.name_raw}
355
+ secondaryOf={clientSecondary}
356
+ icon={UserRound}
357
+ noun="client"
358
+ placeholder="Walk-in"
359
+ testIdPrefix="client-picker"
360
+ />
361
+ </div>
362
+ <div>
363
+ <label class="text-[10px] uppercase tracking-widest text-zinc-500 font-semibold">
364
+ Voucher
365
+ </label>
366
+ <VoucherPicker
367
+ selected={props.voucher}
368
+ onChange={(v) => {
369
+ props.setVoucher(v);
370
+ if (v) props.setManualDiscount("");
371
+ }}
372
+ subtotal={subtotal()}
373
+ packageIds={cartPackageIds()}
374
+ />
375
+ </div>
376
+ </div>
377
+
378
+ <Show when={!props.voucher}>
379
+ <div>
380
+ <label class="text-[10px] uppercase tracking-widest text-zinc-500 font-semibold">
381
+ Manual discount (₱)
382
+ </label>
383
+ <input
384
+ type="number"
385
+ min="0"
386
+ step="0.01"
387
+ value={props.manualDiscount}
388
+ onInput={(e) => props.setManualDiscount(e.currentTarget.value)}
389
+ class="mt-0.5 w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm text-zinc-200 tabular-nums focus:border-emerald-500/50 focus:outline-none"
390
+ />
391
+ </div>
392
+ </Show>
393
+
394
+ <Show when={props.items.length > 0}>
395
+ <div class="border-t border-emerald-500/15 pt-2 text-xs space-y-0.5 tabular-nums">
396
+ <div class="flex items-center justify-between text-zinc-400">
397
+ <span>Subtotal</span>
398
+ <span>{formatPHP(subtotal())}</span>
399
+ </div>
400
+ <Show when={effectiveDiscount() > 0}>
401
+ <div class="flex items-center justify-between text-zinc-400">
402
+ <span>
403
+ {props.voucher
404
+ ? `Voucher ${props.voucher.code}`
405
+ : "Manual discount"}
406
+ </span>
407
+ <span>− {formatPHP(effectiveDiscount())}</span>
408
+ </div>
409
+ </Show>
410
+ <div class="flex items-center justify-between text-zinc-200 font-semibold">
411
+ <span>Total</span>
412
+ <span>{formatPHP(total())}</span>
413
+ </div>
414
+ </div>
415
+ </Show>
416
+ </div>
417
+ );
418
+ }