@kahitsan/ksui 0.39.1 → 0.39.3
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 +1 -1
- package/src/components/composite/AccountRadioPicker.tsx +21 -5
- package/src/components/composite/TransactionAccountFields.tsx +3 -0
- package/src/components/composite/TransactionForm.tsx +79 -70
- package/src/components/composite/VoucherPicker.test.tsx +47 -0
- package/src/components/composite/VoucherPicker.tsx +18 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kahitsan/ksui",
|
|
3
|
-
"version": "0.39.
|
|
3
|
+
"version": "0.39.3",
|
|
4
4
|
"description": "ksui is a standalone set of SolidJS UI components for KahitSan/Hilinga and any SolidJS app. 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 only solid-js externalized; it depends on nothing but solid-js + lucide-solid and injects its own CSS.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createEffect, Show, For } from "solid-js";
|
|
2
2
|
import AccountAvatar from "../base/AccountAvatar";
|
|
3
|
+
import Button from "../base/Button";
|
|
3
4
|
import type { PaymentAccountOption } from "./PaymentAccountPicker";
|
|
4
5
|
|
|
5
6
|
export interface AccountRadioPickerProps {
|
|
@@ -9,6 +10,8 @@ export interface AccountRadioPickerProps {
|
|
|
9
10
|
ariaLabel: string;
|
|
10
11
|
excludeId?: string;
|
|
11
12
|
autoDefault?: boolean;
|
|
13
|
+
compact?: boolean;
|
|
14
|
+
tone?: "income" | "expense" | "transfer";
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
export default function AccountRadioPicker(props: AccountRadioPickerProps) {
|
|
@@ -82,6 +85,7 @@ export default function AccountRadioPicker(props: AccountRadioPickerProps) {
|
|
|
82
85
|
aria-label={props.ariaLabel}
|
|
83
86
|
tabIndex={-1}
|
|
84
87
|
class="grid max-sm:grid-cols-2 sm:grid-cols-3 gap-2"
|
|
88
|
+
classList={{ "ks-transaction-account-picker": props.compact }}
|
|
85
89
|
onKeyDown={onKeyDown}
|
|
86
90
|
>
|
|
87
91
|
<For each={visible()}>
|
|
@@ -89,17 +93,29 @@ export default function AccountRadioPicker(props: AccountRadioPickerProps) {
|
|
|
89
93
|
const selected = () => props.value === a.id.toString();
|
|
90
94
|
const isTabStop = () => selected() || (!props.value && i() === 0);
|
|
91
95
|
return (
|
|
92
|
-
<
|
|
93
|
-
ref={(el) => (buttonRefs[i()] = el)}
|
|
96
|
+
<Button
|
|
97
|
+
ref={(el: HTMLButtonElement) => (buttonRefs[i()] = el)}
|
|
94
98
|
type="button"
|
|
95
99
|
role="radio"
|
|
96
100
|
aria-checked={selected()}
|
|
97
101
|
tabIndex={isTabStop() ? 0 : -1}
|
|
102
|
+
intent="secondary"
|
|
103
|
+
variant="clip1"
|
|
104
|
+
size="sm"
|
|
105
|
+
noGlow
|
|
106
|
+
noRipple
|
|
107
|
+
noScanline
|
|
98
108
|
onClick={() => props.onChange(a.id.toString())}
|
|
99
|
-
class="group flex items-center gap-2
|
|
109
|
+
class="group flex items-center gap-2 px-3 py-3 text-left text-sm transition-colors cursor-pointer"
|
|
100
110
|
classList={{
|
|
101
111
|
"border-[color-mix(in_srgb,var(--ks-accent,#fbbf24)_50%,transparent)] bg-[color-mix(in_srgb,var(--ks-accent,#fbbf24)_10%,transparent)] text-[var(--ks-accent-hover,#fcd34d)]":
|
|
102
|
-
selected(),
|
|
112
|
+
selected() && (!props.compact || !props.tone),
|
|
113
|
+
"border-[color-mix(in_srgb,var(--ks-success,#10b981)_50%,transparent)] bg-[color-mix(in_srgb,var(--ks-success,#10b981)_10%,transparent)] text-[var(--ks-success-fg,#34d399)]":
|
|
114
|
+
selected() && props.compact && props.tone === "income",
|
|
115
|
+
"border-[color-mix(in_srgb,var(--ks-danger,#ef4444)_50%,transparent)] bg-[color-mix(in_srgb,var(--ks-danger,#ef4444)_10%,transparent)] text-[var(--ks-danger-fg,#f87171)]":
|
|
116
|
+
selected() && props.compact && props.tone === "expense",
|
|
117
|
+
"border-[color-mix(in_srgb,var(--ks-info,#38bdf8)_50%,transparent)] bg-[color-mix(in_srgb,var(--ks-info,#38bdf8)_10%,transparent)] text-[var(--ks-info,#38bdf8)]":
|
|
118
|
+
selected() && props.compact && props.tone === "transfer",
|
|
103
119
|
"border-[var(--ks-border-strong,#3f3f46)] bg-[color-mix(in_srgb,var(--ks-surface-raised,#1a1a1a)_50%,transparent)] text-[var(--ks-fg-muted,#a1a1aa)] hover:border-[var(--ks-border-strong,#3f3f46)] hover:bg-[var(--ks-surface-raised,#1a1a1a)]":
|
|
104
120
|
!selected(),
|
|
105
121
|
}}
|
|
@@ -114,7 +130,7 @@ export default function AccountRadioPicker(props: AccountRadioPickerProps) {
|
|
|
114
130
|
}
|
|
115
131
|
/>
|
|
116
132
|
<span class="truncate">{a.name}</span>
|
|
117
|
-
</
|
|
133
|
+
</Button>
|
|
118
134
|
);
|
|
119
135
|
}}
|
|
120
136
|
</For>
|
|
@@ -23,6 +23,7 @@ export interface TransactionAccountFieldsProps {
|
|
|
23
23
|
transferFeeAmount: string;
|
|
24
24
|
transferFeeEnabled: boolean;
|
|
25
25
|
allowTransferFee: boolean;
|
|
26
|
+
compact?: boolean;
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
/**
|
|
@@ -43,6 +44,8 @@ export default function TransactionAccountFields(props: TransactionAccountFields
|
|
|
43
44
|
value={
|
|
44
45
|
props.category === "sale" ? props.destAccount : props.sourceAccount
|
|
45
46
|
}
|
|
47
|
+
compact={props.compact}
|
|
48
|
+
tone={props.category === "sale" ? "income" : "expense"}
|
|
46
49
|
onChange={(v) => {
|
|
47
50
|
if (props.category === "sale") {
|
|
48
51
|
props.setDestAccount(v);
|
|
@@ -299,10 +299,35 @@ export interface TransactionFormProps {
|
|
|
299
299
|
}
|
|
300
300
|
|
|
301
301
|
export default function TransactionForm(props: TransactionFormProps) {
|
|
302
|
-
injectCSS(
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
302
|
+
injectCSS(
|
|
303
|
+
"ksui-transaction-form-compact",
|
|
304
|
+
`.ks-transaction-compact{display:flex;flex-direction:column;min-height:0;height:100%;font-family:var(--ks-font-body,"Inter", system-ui, sans-serif)}
|
|
305
|
+
.ks-transaction-compact form{display:flex!important;flex:1 1 auto!important;flex-direction:column!important;min-height:0!important;overflow:hidden!important}
|
|
306
|
+
.ks-transaction-compact-type-switcher{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:.375rem;margin:0 1.25rem .25rem;padding:.375rem;border-radius:.75rem;background:color-mix(in srgb,var(--ks-fg,#ffffff) 5%,transparent)}
|
|
307
|
+
.ks-transaction-compact-type-switcher>button{min-height:2.25rem;border:0;border-radius:.5rem;padding:.5rem .25rem}
|
|
308
|
+
.ks-transaction-compact-type-switcher>button.ks-transaction-type-active{background:color-mix(in srgb,var(--ks-primary,#c9a961) 16%,transparent);color:var(--ks-primary,#c9a961)}
|
|
309
|
+
.ks-transaction-compact-body{display:flex!important;flex:1 1 auto!important;flex-direction:column!important;width:100%!important;min-height:0!important;overflow-x:hidden!important;overflow-y:auto!important;padding:1rem 1.25rem 1.5rem!important;gap:1rem}
|
|
310
|
+
.ks-transaction-compact-body>div{width:100%!important;flex:0 0 auto!important}
|
|
311
|
+
.ks-transaction-compact-footer{flex:0 0 auto!important}
|
|
312
|
+
.ks-transaction-compact-body>.ks-transaction-compact-amount{order:2;padding:0;background:transparent}
|
|
313
|
+
.ks-transaction-compact-body>.ks-transaction-compact-description{order:3}
|
|
314
|
+
.ks-transaction-compact-body>.ks-transaction-compact-date{order:4;padding:.75rem;border:1px solid color-mix(in srgb,var(--ks-border,rgba(39,39,42,0.5)) 70%,transparent);border-radius:.75rem;background:color-mix(in srgb,var(--ks-overlay,rgba(0,0,0,0.7)) 78%,transparent)}
|
|
315
|
+
.ks-transaction-compact-body>.ks-transaction-compact-account{order:5}
|
|
316
|
+
.ks-transaction-compact-body>.ks-transaction-compact-details-toggle{order:6;display:flex;align-items:center;gap:.75rem;width:100%;padding:.25rem 0;color:var(--ks-fg-subtle,#71717a)}
|
|
317
|
+
.ks-transaction-compact-body>button:before,.ks-transaction-compact-body>button:after{content:"";height:1px;flex:1;background:color-mix(in srgb,var(--ks-fg,#ffffff) 8%,transparent)}
|
|
318
|
+
.ks-transaction-compact-body>div:has(input[data-testid="transactions-form-amount"])>label,.ks-transaction-compact-body>div:has(input[data-testid="transactions-form-description"])>label{display:none}
|
|
319
|
+
.ks-transaction-compact-body input,.ks-transaction-compact-body select,.ks-transaction-compact-body textarea{border-radius:.625rem}
|
|
320
|
+
.ks-transaction-compact-body input[data-testid="transactions-form-amount"]{height:4rem;font-family:var(--ks-font-mono,ui-monospace,"SF Mono","Cascadia Code",Menlo,Consolas,monospace);font-size:3.25rem;line-height:1;text-align:center}
|
|
321
|
+
.ks-transaction-compact-body input[data-testid="transactions-form-description"]{background:transparent;border-color:var(--ks-border)}
|
|
322
|
+
.ks-transaction-compact-body>div:has(button[aria-label="Today"])>label{font-size:10px;display:flex;align-items:center;gap:.5rem;color:color-mix(in srgb,var(--ks-fg,#ffffff) 35%,transparent);text-transform:uppercase;letter-spacing:.12em}
|
|
323
|
+
.ks-transaction-compact-body>div:has(button[aria-label="Today"])>label:before{content:"◷";font-size:15px;color:var(--ks-fg-muted,#a1a1aa)}
|
|
324
|
+
.ks-transaction-compact-body [role="radiogroup"]{display:flex;flex-wrap:nowrap;overflow-x:auto;scrollbar-width:none}
|
|
325
|
+
.ks-transaction-compact-body [role="radiogroup"]::-webkit-scrollbar{display:none}
|
|
326
|
+
.ks-transaction-compact-body [role="radio"]{min-width:8rem}
|
|
327
|
+
.ks-transaction-compact:not(.ks-transaction-compact-details) .ks-transaction-compact-body>div:has([data-testid="form-payee-picker-trigger"]),.ks-transaction-compact:not(.ks-transaction-compact-details) .ks-transaction-compact-body>div:has([data-testid="subcategory-select"]),.ks-transaction-compact:not(.ks-transaction-compact-details) .ks-transaction-compact-body>div:has(textarea),.ks-transaction-compact:not(.ks-transaction-compact-details) .ks-transaction-compact-body>div:has([data-testid="mention-textarea"]),.ks-transaction-compact:not(.ks-transaction-compact-details) .ks-transaction-compact-body>div:has(button[aria-label="Add attachment"]){display:none}
|
|
328
|
+
.ks-transaction-compact-footer>div:last-child>button{min-height:2.75rem}
|
|
329
|
+
`,
|
|
330
|
+
);
|
|
306
331
|
const compact = () => props.layout === "compact";
|
|
307
332
|
const catConfig = () =>
|
|
308
333
|
CATEGORY_FORM[props.category] || CATEGORY_FORM.expense;
|
|
@@ -446,6 +471,39 @@ export default function TransactionForm(props: TransactionFormProps) {
|
|
|
446
471
|
</div>
|
|
447
472
|
</Show>
|
|
448
473
|
|
|
474
|
+
<Show when={compact() && !props.simpleMode}>
|
|
475
|
+
<div class="ks-transaction-compact-type-switcher" role="tablist" aria-label="Transaction type">
|
|
476
|
+
<For each={categoryOptions()}>
|
|
477
|
+
{(cat) => (
|
|
478
|
+
<Button
|
|
479
|
+
type="button"
|
|
480
|
+
role="tab"
|
|
481
|
+
aria-selected={props.category === cat}
|
|
482
|
+
data-testid={`transactions-form-category-${cat}`}
|
|
483
|
+
intent="secondary"
|
|
484
|
+
variant="ghost"
|
|
485
|
+
size="sm"
|
|
486
|
+
noGlow
|
|
487
|
+
noRipple
|
|
488
|
+
noScanline
|
|
489
|
+
class={`ks-transaction-type-button ks-transaction-type-${cat}`}
|
|
490
|
+
classList={{ "ks-transaction-type-active": props.category === cat }}
|
|
491
|
+
onClick={() => {
|
|
492
|
+
props.setCategory(cat);
|
|
493
|
+
props.setSourceAccount("");
|
|
494
|
+
props.setDestAccount("");
|
|
495
|
+
if (cat !== "business") {
|
|
496
|
+
props.setTransferFeeEnabled(false);
|
|
497
|
+
props.setTransferFeeAmount("");
|
|
498
|
+
}
|
|
499
|
+
}}
|
|
500
|
+
>
|
|
501
|
+
{CATEGORY_FORM[cat].label}
|
|
502
|
+
</Button>
|
|
503
|
+
)}
|
|
504
|
+
</For>
|
|
505
|
+
</div>
|
|
506
|
+
</Show>
|
|
449
507
|
<Show when={cameraOpen()}>
|
|
450
508
|
<CameraCapture
|
|
451
509
|
onCapture={(file) => {
|
|
@@ -474,56 +532,6 @@ export default function TransactionForm(props: TransactionFormProps) {
|
|
|
474
532
|
</div>
|
|
475
533
|
</Show>
|
|
476
534
|
|
|
477
|
-
<Show when={!props.simpleMode}>
|
|
478
|
-
<div>
|
|
479
|
-
<div class="text-[10px] uppercase tracking-widest text-[var(--ks-fg-subtle,#71717a)] font-semibold mb-2">
|
|
480
|
-
Type
|
|
481
|
-
</div>
|
|
482
|
-
<div
|
|
483
|
-
class="grid gap-2"
|
|
484
|
-
classList={{
|
|
485
|
-
"grid-cols-3": categoryOptions().length === 3,
|
|
486
|
-
"grid-cols-4": categoryOptions().length === 4,
|
|
487
|
-
}}
|
|
488
|
-
>
|
|
489
|
-
<For each={categoryOptions()}>
|
|
490
|
-
{(cat) => {
|
|
491
|
-
const cfg = CATEGORY_FORM[cat];
|
|
492
|
-
const tone = CATEGORY_TONE[cat];
|
|
493
|
-
const tc = TONE_CLASSES[tone.tone];
|
|
494
|
-
const Ico = tone.icon;
|
|
495
|
-
return (
|
|
496
|
-
<button
|
|
497
|
-
type="button"
|
|
498
|
-
data-testid={`transactions-form-category-${cat}`}
|
|
499
|
-
onClick={() => {
|
|
500
|
-
props.setCategory(cat);
|
|
501
|
-
props.setSourceAccount("");
|
|
502
|
-
props.setDestAccount("");
|
|
503
|
-
if (cat !== "business") {
|
|
504
|
-
props.setTransferFeeEnabled(false);
|
|
505
|
-
props.setTransferFeeAmount("");
|
|
506
|
-
}
|
|
507
|
-
}}
|
|
508
|
-
class="flex min-h-[42px] items-center justify-center gap-2 px-3 py-2 border text-sm transition-colors ks-hud-clip-button cursor-pointer active:opacity-80"
|
|
509
|
-
classList={{
|
|
510
|
-
[`${tc.bg} ${tc.border} ${tc.text}`]:
|
|
511
|
-
props.category === cat,
|
|
512
|
-
"border-[var(--ks-border,rgba(39,39,42,0.5))] bg-transparent text-[var(--ks-fg-subtle,#71717a)] hover:border-[var(--ks-input-border,#3f3f46)] hover:text-[var(--ks-fg,#ffffff)]":
|
|
513
|
-
props.category !== cat,
|
|
514
|
-
}}
|
|
515
|
-
>
|
|
516
|
-
<Ico size={16} />
|
|
517
|
-
<span class="font-medium">{cfg.label}</span>
|
|
518
|
-
</button>
|
|
519
|
-
);
|
|
520
|
-
}}
|
|
521
|
-
</For>
|
|
522
|
-
</div>
|
|
523
|
-
<p class="text-[11px] text-[var(--ks-fg-subtle,#71717a)] mt-2">{catConfig().hint}</p>
|
|
524
|
-
</div>
|
|
525
|
-
</Show>
|
|
526
|
-
|
|
527
535
|
<Show when={props.category === "sale"}>
|
|
528
536
|
<SalesBodyEditor
|
|
529
537
|
items={props.saleItems}
|
|
@@ -540,6 +548,7 @@ export default function TransactionForm(props: TransactionFormProps) {
|
|
|
540
548
|
<Show
|
|
541
549
|
when={!(props.category === "sale" && props.saleItems.length > 0)}
|
|
542
550
|
>
|
|
551
|
+
<div class={compact() ? "ks-transaction-compact-amount" : undefined}>
|
|
543
552
|
<FormField label={compact() ? "" : "Amount *"}>
|
|
544
553
|
<div class="flex items-stretch gap-2 px-4 py-3 border bg-[color-mix(in_srgb,var(--ks-overlay-surface,#18181b)_60%,transparent)] border-[color-mix(in_srgb,var(--ks-border,rgba(39,39,42,0.5))_60%,transparent)] ks-hud-clip-button focus-within:border-[color-mix(in_srgb,var(--ks-focus-ring,#c9a961)_50%,transparent)] transition-colors">
|
|
545
554
|
<span class="self-center text-3xl font-bold text-[var(--ks-fg-subtle,#71717a)] tabular-nums">
|
|
@@ -570,6 +579,7 @@ export default function TransactionForm(props: TransactionFormProps) {
|
|
|
570
579
|
</Show>
|
|
571
580
|
</div>
|
|
572
581
|
</FormField>
|
|
582
|
+
</div>
|
|
573
583
|
<Show
|
|
574
584
|
when={
|
|
575
585
|
props.category === "business" &&
|
|
@@ -607,6 +617,7 @@ export default function TransactionForm(props: TransactionFormProps) {
|
|
|
607
617
|
</Show>
|
|
608
618
|
</Show>
|
|
609
619
|
|
|
620
|
+
<div class={compact() ? "ks-transaction-compact-date" : undefined}>
|
|
610
621
|
<FormField label={compact() ? "TRANSACTION DATE" : "Date *"}>
|
|
611
622
|
<DatePicker
|
|
612
623
|
value={props.date}
|
|
@@ -619,6 +630,7 @@ export default function TransactionForm(props: TransactionFormProps) {
|
|
|
619
630
|
</p>
|
|
620
631
|
</Show>
|
|
621
632
|
</FormField>
|
|
633
|
+
</div>
|
|
622
634
|
|
|
623
635
|
<Show when={props.isBackdated}>
|
|
624
636
|
<div class="rounded-lg border border-[color-mix(in_srgb,var(--ks-warning,#f59e0b)_20%,transparent)] bg-[color-mix(in_srgb,var(--ks-warning,#f59e0b)_5%,transparent)] px-3 py-2">
|
|
@@ -638,6 +650,7 @@ export default function TransactionForm(props: TransactionFormProps) {
|
|
|
638
650
|
</div>
|
|
639
651
|
</Show>
|
|
640
652
|
|
|
653
|
+
<div class={compact() ? "ks-transaction-compact-description" : undefined}>
|
|
641
654
|
<FormField label={compact() ? "" : "Description *"}>
|
|
642
655
|
<input
|
|
643
656
|
type="text"
|
|
@@ -649,9 +662,10 @@ export default function TransactionForm(props: TransactionFormProps) {
|
|
|
649
662
|
required
|
|
650
663
|
/>
|
|
651
664
|
</FormField>
|
|
665
|
+
</div>
|
|
652
666
|
|
|
653
667
|
<Show when={catConfig().showPayee}>
|
|
654
|
-
<div class="grid grid-cols-2 gap-4"
|
|
668
|
+
<div class="grid grid-cols-2 gap-4">
|
|
655
669
|
<FormField label={catConfig().payeeLabel!}>
|
|
656
670
|
<ComboBox<PayeeOption>
|
|
657
671
|
testIdPrefix="form-payee-picker"
|
|
@@ -699,8 +713,7 @@ export default function TransactionForm(props: TransactionFormProps) {
|
|
|
699
713
|
</div>
|
|
700
714
|
</Show>
|
|
701
715
|
<Show when={!catConfig().showPayee}>
|
|
702
|
-
<
|
|
703
|
-
<FormField label="Reference #">
|
|
716
|
+
<FormField label="Reference #">
|
|
704
717
|
<input
|
|
705
718
|
type="text"
|
|
706
719
|
value={props.refNumber}
|
|
@@ -708,13 +721,11 @@ export default function TransactionForm(props: TransactionFormProps) {
|
|
|
708
721
|
class="w-full bg-[color-mix(in_srgb,var(--ks-overlay-surface,#18181b)_60%,transparent)] border border-[color-mix(in_srgb,var(--ks-border,rgba(39,39,42,0.5))_60%,transparent)] px-3 py-3 text-sm text-[var(--ks-fg,#ffffff)] ks-hud-clip-button focus:outline-none focus:border-[color-mix(in_srgb,var(--ks-focus-ring,#c9a961)_50%,transparent)]"
|
|
709
722
|
placeholder="Reference number (optional)"
|
|
710
723
|
/>
|
|
711
|
-
|
|
712
|
-
</div>
|
|
724
|
+
</FormField>
|
|
713
725
|
</Show>
|
|
714
726
|
|
|
715
727
|
<Show when={subcategoryAppliesTo() !== null}>
|
|
716
|
-
<
|
|
717
|
-
<FormField label="Category">
|
|
728
|
+
<FormField label="Category">
|
|
718
729
|
<Show
|
|
719
730
|
when={subcategoryOptionsReady()}
|
|
720
731
|
fallback={
|
|
@@ -760,13 +771,11 @@ export default function TransactionForm(props: TransactionFormProps) {
|
|
|
760
771
|
<p class="text-[10px] text-[var(--ks-fg-subtle,#71717a)] mt-0.5">
|
|
761
772
|
Optional. Used for tax-prep classification.
|
|
762
773
|
</p>
|
|
763
|
-
|
|
764
|
-
</div>
|
|
774
|
+
</FormField>
|
|
765
775
|
</Show>
|
|
766
776
|
|
|
767
777
|
<Show when={props.category === "payable"}>
|
|
768
|
-
<
|
|
769
|
-
<TransactionPayableFields
|
|
778
|
+
<TransactionPayableFields
|
|
770
779
|
payableKind={props.payableKind}
|
|
771
780
|
setPayableKind={props.setPayableKind}
|
|
772
781
|
dueDate={props.dueDate}
|
|
@@ -775,10 +784,10 @@ export default function TransactionForm(props: TransactionFormProps) {
|
|
|
775
784
|
setChequeNumber={props.setChequeNumber}
|
|
776
785
|
pdcStatus={props.pdcStatus}
|
|
777
786
|
setPdcStatus={props.setPdcStatus}
|
|
778
|
-
|
|
779
|
-
</div>
|
|
787
|
+
/>
|
|
780
788
|
</Show>
|
|
781
789
|
|
|
790
|
+
<div class={compact() ? "ks-transaction-compact-account" : undefined}>
|
|
782
791
|
<TransactionAccountFields
|
|
783
792
|
category={props.category}
|
|
784
793
|
accounts={props.accounts}
|
|
@@ -794,9 +803,10 @@ export default function TransactionForm(props: TransactionFormProps) {
|
|
|
794
803
|
transferFeeAmount={props.transferFeeAmount}
|
|
795
804
|
transferFeeEnabled={props.transferFeeEnabled}
|
|
796
805
|
allowTransferFee={props.allowTransferFee}
|
|
806
|
+
compact={compact()}
|
|
797
807
|
/>
|
|
808
|
+
</div>
|
|
798
809
|
|
|
799
|
-
<div classList={{ "ks-transaction-compact-secondary": compact() }}>
|
|
800
810
|
<FormField label="Notes">
|
|
801
811
|
<MentionTextarea
|
|
802
812
|
value={props.notes}
|
|
@@ -807,7 +817,6 @@ export default function TransactionForm(props: TransactionFormProps) {
|
|
|
807
817
|
ariaLabel="Notes"
|
|
808
818
|
/>
|
|
809
819
|
</FormField>
|
|
810
|
-
</div>
|
|
811
820
|
|
|
812
821
|
<div>
|
|
813
822
|
<div class="flex items-center gap-1 mb-2 text-xs text-[var(--ks-fg-subtle,#71717a)]">
|
|
@@ -908,7 +917,7 @@ export default function TransactionForm(props: TransactionFormProps) {
|
|
|
908
917
|
</div>
|
|
909
918
|
|
|
910
919
|
<Show when={!props.simpleMode}>
|
|
911
|
-
<div class="flex justify-center pt-2">
|
|
920
|
+
<div class="flex justify-center pt-2" classList={{ "ks-transaction-compact-details-toggle": compact() }}>
|
|
912
921
|
<button
|
|
913
922
|
type="button"
|
|
914
923
|
onClick={() =>
|
|
@@ -37,6 +37,7 @@ function voucher(over: Partial<VoucherOption> & Pick<VoucherOption, "id" | "code
|
|
|
37
37
|
value: 20,
|
|
38
38
|
max_discount_amount: null,
|
|
39
39
|
applicable_packages: null,
|
|
40
|
+
applicable_package_lineages: null,
|
|
40
41
|
minimum_purchase: 0,
|
|
41
42
|
valid_from: null,
|
|
42
43
|
valid_until: null,
|
|
@@ -379,6 +380,52 @@ describe("VoucherPicker dialog", () => {
|
|
|
379
380
|
);
|
|
380
381
|
});
|
|
381
382
|
|
|
383
|
+
it("accepts a voucher when each package matches by aligned lineage", async () => {
|
|
384
|
+
mockPagedFetch([
|
|
385
|
+
voucher({
|
|
386
|
+
id: 35,
|
|
387
|
+
code: "LINEAGEOK",
|
|
388
|
+
applicable_packages: [99],
|
|
389
|
+
applicable_package_lineages: ["day-pass"],
|
|
390
|
+
}),
|
|
391
|
+
]);
|
|
392
|
+
const { getByTestId } = render(() => (
|
|
393
|
+
<VoucherPicker
|
|
394
|
+
selected={null}
|
|
395
|
+
onChange={vi.fn()}
|
|
396
|
+
subtotal={1000}
|
|
397
|
+
packageIds={[1]}
|
|
398
|
+
packageLineages={["day-pass"]}
|
|
399
|
+
/>
|
|
400
|
+
));
|
|
401
|
+
fireEvent.click(getByTestId("voucher-picker-trigger"));
|
|
402
|
+
await waitFor(() => expect(getByTestId("voucher-picker-result-35")).toBeTruthy());
|
|
403
|
+
});
|
|
404
|
+
it("rejects a voucher when aligned package lineage does not match", async () => {
|
|
405
|
+
mockPagedFetch([
|
|
406
|
+
voucher({
|
|
407
|
+
id: 36,
|
|
408
|
+
code: "LINEAGEBAD",
|
|
409
|
+
applicable_packages: [99],
|
|
410
|
+
applicable_package_lineages: ["day-pass"],
|
|
411
|
+
}),
|
|
412
|
+
]);
|
|
413
|
+
const { getByTestId, queryByTestId } = render(() => (
|
|
414
|
+
<VoucherPicker
|
|
415
|
+
selected={null}
|
|
416
|
+
onChange={vi.fn()}
|
|
417
|
+
subtotal={1000}
|
|
418
|
+
packageIds={[1]}
|
|
419
|
+
packageLineages={["single-use"]}
|
|
420
|
+
/>
|
|
421
|
+
));
|
|
422
|
+
fireEvent.click(getByTestId("voucher-picker-trigger"));
|
|
423
|
+
await waitFor(() => expect(getByTestId("voucher-picker-inapplicable-36")).toBeTruthy());
|
|
424
|
+
expect(queryByTestId("voucher-picker-result-36")).toBeNull();
|
|
425
|
+
expect(getByTestId("voucher-picker-inapplicable-36").textContent).toContain(
|
|
426
|
+
"Doesn't cover every item",
|
|
427
|
+
);
|
|
428
|
+
});
|
|
382
429
|
it("surfaces a load failure instead of rendering an empty list", async () => {
|
|
383
430
|
vi.stubGlobal(
|
|
384
431
|
"fetch",
|
|
@@ -23,6 +23,7 @@ export interface VoucherOption {
|
|
|
23
23
|
value: string | number | null;
|
|
24
24
|
max_discount_amount: string | number | null;
|
|
25
25
|
applicable_packages: number[] | null;
|
|
26
|
+
applicable_package_lineages?: string[] | null;
|
|
26
27
|
minimum_purchase: string | number;
|
|
27
28
|
valid_from: string | null;
|
|
28
29
|
valid_until: string | null;
|
|
@@ -42,6 +43,7 @@ interface VoucherPickerProps {
|
|
|
42
43
|
onChange: (next: VoucherOption | null) => void;
|
|
43
44
|
subtotal: number;
|
|
44
45
|
packageIds: number[];
|
|
46
|
+
packageLineages?: (string | null)[];
|
|
45
47
|
disabled?: boolean;
|
|
46
48
|
compact?: boolean;
|
|
47
49
|
/** Same-shape endpoint override (defaults to the vouchers plugin's own API) —
|
|
@@ -104,6 +106,7 @@ function ineligibilityReason(
|
|
|
104
106
|
voucher: VoucherOption,
|
|
105
107
|
subtotal: number,
|
|
106
108
|
packageIds: number[],
|
|
109
|
+
packageLineages: (string | null)[],
|
|
107
110
|
todayIso: string,
|
|
108
111
|
): string | null {
|
|
109
112
|
if (!voucher.is_active) return "Inactive";
|
|
@@ -116,10 +119,17 @@ function ineligibilityReason(
|
|
|
116
119
|
if (usageExhausted(voucher)) return "Fully redeemed";
|
|
117
120
|
if (asNumber(voucher.minimum_purchase) > subtotal)
|
|
118
121
|
return `Needs ${formatCurrency(asNumber(voucher.minimum_purchase))} minimum`;
|
|
119
|
-
|
|
122
|
+
const allowedIds = voucher.applicable_packages ?? [];
|
|
123
|
+
const allowedLineages = voucher.applicable_package_lineages ?? [];
|
|
124
|
+
if (allowedIds.length > 0 || allowedLineages.length > 0) {
|
|
120
125
|
if (packageIds.length === 0) return "Only for specific items";
|
|
121
|
-
const allowed = new Set(
|
|
122
|
-
if (
|
|
126
|
+
const allowed = new Set(allowedIds);
|
|
127
|
+
if (packageIds.some((id, index) =>
|
|
128
|
+
!allowed.has(id) &&
|
|
129
|
+
!(packageLineages[index] != null && allowedLineages.includes(packageLineages[index]!))
|
|
130
|
+
)) {
|
|
131
|
+
return "Doesn't cover every item";
|
|
132
|
+
}
|
|
123
133
|
}
|
|
124
134
|
return null;
|
|
125
135
|
}
|
|
@@ -129,9 +139,10 @@ function isApplicable(
|
|
|
129
139
|
voucher: VoucherOption,
|
|
130
140
|
subtotal: number,
|
|
131
141
|
packageIds: number[],
|
|
142
|
+
packageLineages: (string | null)[],
|
|
132
143
|
todayIso: string,
|
|
133
144
|
): boolean {
|
|
134
|
-
return ineligibilityReason(voucher, subtotal, packageIds, todayIso) === null;
|
|
145
|
+
return ineligibilityReason(voucher, subtotal, packageIds, packageLineages, todayIso) === null;
|
|
135
146
|
}
|
|
136
147
|
|
|
137
148
|
function formatVoucherDescription(v: VoucherOption): string {
|
|
@@ -293,16 +304,16 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
|
|
|
293
304
|
|
|
294
305
|
const applicable = createMemo(() => {
|
|
295
306
|
const today_ = today();
|
|
296
|
-
return vouchers().filter((v) => isApplicable(v, props.subtotal, props.packageIds, today_));
|
|
307
|
+
return vouchers().filter((v) => isApplicable(v, props.subtotal, props.packageIds, props.packageLineages ?? [], today_));
|
|
297
308
|
});
|
|
298
309
|
|
|
299
310
|
const inapplicable = createMemo(() => {
|
|
300
311
|
const today_ = today();
|
|
301
312
|
return vouchers()
|
|
302
|
-
.filter((v) => !isApplicable(v, props.subtotal, props.packageIds, today_))
|
|
313
|
+
.filter((v) => !isApplicable(v, props.subtotal, props.packageIds, props.packageLineages ?? [], today_))
|
|
303
314
|
.map((v) => ({
|
|
304
315
|
voucher: v,
|
|
305
|
-
reason: ineligibilityReason(v, props.subtotal, props.packageIds, today_) ?? "",
|
|
316
|
+
reason: ineligibilityReason(v, props.subtotal, props.packageIds, props.packageLineages ?? [], today_) ?? "",
|
|
306
317
|
}));
|
|
307
318
|
});
|
|
308
319
|
|