@kahitsan/ksui 0.46.0 → 0.47.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 +1 -1
- package/src/components/base/Modal.test.tsx +69 -3
- package/src/components/base/Modal.tsx +17 -2
- package/src/components/composite/TransactionForm.payee-tin.test.tsx +224 -0
- package/src/components/composite/TransactionForm.tsx +94 -0
- package/src/components/composite/payee-tin.ts +15 -0
- package/src/components/composite/picker-types.ts +3 -0
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kahitsan/ksui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.47.0",
|
|
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,6 +1,5 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { render } from "@solidjs/testing-library";
|
|
3
|
-
import { Show, createSignal } from "solid-js";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { cleanup, render } from "@solidjs/testing-library";
|
|
4
3
|
import Modal from "./Modal";
|
|
5
4
|
|
|
6
5
|
// Modal is the most critical shared widget — it wraps a native <dialog> with
|
|
@@ -68,4 +67,71 @@ describe("Modal", () => {
|
|
|
68
67
|
const card = container.querySelector(".ksui-modal-card")!;
|
|
69
68
|
expect(card.className).toContain("danger");
|
|
70
69
|
});
|
|
70
|
+
|
|
71
|
+
describe("sheet variant", () => {
|
|
72
|
+
// jsdom has no showModal()/close(); stand them in so a stacked dialog is open.
|
|
73
|
+
const proto = HTMLDialogElement.prototype;
|
|
74
|
+
const original = { showModal: proto.showModal, close: proto.close };
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
proto.showModal = function (this: HTMLDialogElement) {
|
|
77
|
+
this.setAttribute("open", "");
|
|
78
|
+
};
|
|
79
|
+
proto.close = function (this: HTMLDialogElement) {
|
|
80
|
+
this.removeAttribute("open");
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
afterEach(() => {
|
|
84
|
+
cleanup(); // unmount while the stubs still exist
|
|
85
|
+
proto.showModal = original.showModal;
|
|
86
|
+
proto.close = original.close;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("calls onClose on Escape", () => {
|
|
90
|
+
const onClose = vi.fn();
|
|
91
|
+
render(() => (
|
|
92
|
+
<Modal variant="sheet" onClose={onClose}>
|
|
93
|
+
<p>sheet</p>
|
|
94
|
+
</Modal>
|
|
95
|
+
));
|
|
96
|
+
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
|
97
|
+
expect(onClose).toHaveBeenCalledOnce();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("leaves Escape to a dialog opened on top of it", () => {
|
|
101
|
+
const onSheetClose = vi.fn();
|
|
102
|
+
const onDialogClose = vi.fn();
|
|
103
|
+
const { container } = render(() => (
|
|
104
|
+
<Modal variant="sheet" onClose={onSheetClose}>
|
|
105
|
+
<Modal onClose={onDialogClose}>
|
|
106
|
+
<button type="button">inside</button>
|
|
107
|
+
</Modal>
|
|
108
|
+
</Modal>
|
|
109
|
+
));
|
|
110
|
+
const dialog = container.querySelector("dialog")!;
|
|
111
|
+
expect(dialog.open).toBe(true);
|
|
112
|
+
|
|
113
|
+
// A browser delivers Escape as keydown (bubbling to window) and then
|
|
114
|
+
// fires cancel on the topmost modal dialog.
|
|
115
|
+
dialog
|
|
116
|
+
.querySelector("button")!
|
|
117
|
+
.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
|
118
|
+
dialog.dispatchEvent(new Event("cancel", { cancelable: true }));
|
|
119
|
+
|
|
120
|
+
expect(onSheetClose).not.toHaveBeenCalled();
|
|
121
|
+
expect(onDialogClose).toHaveBeenCalledOnce();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("ignores an Escape keydown another handler already consumed", () => {
|
|
125
|
+
const onClose = vi.fn();
|
|
126
|
+
render(() => (
|
|
127
|
+
<Modal variant="sheet" onClose={onClose}>
|
|
128
|
+
<p>sheet</p>
|
|
129
|
+
</Modal>
|
|
130
|
+
));
|
|
131
|
+
const e = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true });
|
|
132
|
+
e.preventDefault();
|
|
133
|
+
window.dispatchEvent(e);
|
|
134
|
+
expect(onClose).not.toHaveBeenCalled();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
71
137
|
});
|
|
@@ -155,13 +155,28 @@ function DialogModal(local: LocalProps): JSX.Element {
|
|
|
155
155
|
);
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
function openModalDialogs(): HTMLDialogElement[] {
|
|
159
|
+
const open = [...document.querySelectorAll<HTMLDialogElement>("dialog[open]")];
|
|
160
|
+
try {
|
|
161
|
+
return open.filter((d) => d.matches(":modal"));
|
|
162
|
+
} catch {
|
|
163
|
+
// Engines without :modal support; treat every open dialog as modal.
|
|
164
|
+
return open;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
158
168
|
function SheetModal(local: LocalProps, size?: ModalSize): JSX.Element {
|
|
169
|
+
let overlayEl: HTMLDivElement | undefined;
|
|
170
|
+
|
|
159
171
|
lockPullToRefresh();
|
|
160
172
|
onCleanup(unlockPullToRefresh);
|
|
161
173
|
|
|
162
174
|
const handleKey = (e: KeyboardEvent) => {
|
|
163
|
-
if (e.key !== "Escape") return;
|
|
175
|
+
if (e.key !== "Escape" || e.defaultPrevented) return;
|
|
164
176
|
if (local.dismissable === false) return;
|
|
177
|
+
// A modal <dialog> stacked above this sheet owns Escape (it gets `cancel`);
|
|
178
|
+
// closing here too would discard the sheet underneath.
|
|
179
|
+
if (openModalDialogs().some((d) => !d.contains(overlayEl ?? null))) return;
|
|
165
180
|
local.onClose();
|
|
166
181
|
};
|
|
167
182
|
if (typeof window !== "undefined") {
|
|
@@ -179,7 +194,7 @@ function SheetModal(local: LocalProps, size?: ModalSize): JSX.Element {
|
|
|
179
194
|
// ModalLayerContext value is already ambient (an outer DialogModal's
|
|
180
195
|
// dialogEl if nested inside one, otherwise the default null/document.body).
|
|
181
196
|
return (
|
|
182
|
-
<div class="ksui-modal-sheet-overlay">
|
|
197
|
+
<div ref={overlayEl} class="ksui-modal-sheet-overlay">
|
|
183
198
|
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
|
184
199
|
<div class="ksui-modal-sheet-backdrop" onClick={handleBackdropClick} />
|
|
185
200
|
<div
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// Opt-in tax-ID capture next to the payee picker: shows the picked payee's TIN
|
|
2
|
+
// and address, offers inputs when they are missing, and warns when withholding
|
|
3
|
+
// is on but no TIN is known.
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { render, fireEvent, waitFor } from "@solidjs/testing-library";
|
|
6
|
+
import TransactionForm, { type TransactionFormProps } from "./TransactionForm";
|
|
7
|
+
import { isValidTin } from "./payee-tin";
|
|
8
|
+
|
|
9
|
+
function baseProps(overrides: Partial<TransactionFormProps> = {}): TransactionFormProps {
|
|
10
|
+
const noop = vi.fn();
|
|
11
|
+
return {
|
|
12
|
+
error: "",
|
|
13
|
+
saving: false,
|
|
14
|
+
category: "expense",
|
|
15
|
+
setCategory: noop,
|
|
16
|
+
subcategory: "",
|
|
17
|
+
setSubcategory: noop,
|
|
18
|
+
sourceAccount: "",
|
|
19
|
+
setSourceAccount: noop,
|
|
20
|
+
destAccount: "",
|
|
21
|
+
setDestAccount: noop,
|
|
22
|
+
amount: "",
|
|
23
|
+
setAmount: noop,
|
|
24
|
+
description: "",
|
|
25
|
+
setDescription: noop,
|
|
26
|
+
notes: "",
|
|
27
|
+
setNotes: noop,
|
|
28
|
+
date: "2026-09-10",
|
|
29
|
+
setDate: noop,
|
|
30
|
+
isPrivate: false,
|
|
31
|
+
setIsPrivate: noop,
|
|
32
|
+
sharedWith: [],
|
|
33
|
+
setSharedWith: noop,
|
|
34
|
+
sharedRoleCodes: [],
|
|
35
|
+
setSharedRoleCodes: noop,
|
|
36
|
+
backdateReason: "",
|
|
37
|
+
setBackdateReason: noop,
|
|
38
|
+
payee: "Acme Hardware",
|
|
39
|
+
setPayee: noop,
|
|
40
|
+
payeeId: 7,
|
|
41
|
+
setPayeeId: noop,
|
|
42
|
+
refNumber: "",
|
|
43
|
+
setRefNumber: noop,
|
|
44
|
+
taxType: "vat_inclusive",
|
|
45
|
+
setTaxType: noop,
|
|
46
|
+
hasEwt: false,
|
|
47
|
+
setHasEwt: noop,
|
|
48
|
+
ewtRate: "",
|
|
49
|
+
setEwtRate: noop,
|
|
50
|
+
payableKind: "",
|
|
51
|
+
setPayableKind: noop,
|
|
52
|
+
dueDate: "",
|
|
53
|
+
setDueDate: noop,
|
|
54
|
+
chequeNumber: "",
|
|
55
|
+
setChequeNumber: noop,
|
|
56
|
+
pdcStatus: "",
|
|
57
|
+
setPdcStatus: noop,
|
|
58
|
+
transferFeeEnabled: false,
|
|
59
|
+
setTransferFeeEnabled: noop,
|
|
60
|
+
transferFeeAmount: "",
|
|
61
|
+
setTransferFeeAmount: noop,
|
|
62
|
+
allowTransferFee: false,
|
|
63
|
+
pendingFiles: [],
|
|
64
|
+
setPendingFiles: noop,
|
|
65
|
+
accounts: [],
|
|
66
|
+
orgMembers: [],
|
|
67
|
+
shareableRoles: [],
|
|
68
|
+
isAdmin: true,
|
|
69
|
+
canShare: false,
|
|
70
|
+
isBackdated: false,
|
|
71
|
+
saleItems: [],
|
|
72
|
+
setSaleItems: noop,
|
|
73
|
+
saleClient: null,
|
|
74
|
+
setSaleClient: noop,
|
|
75
|
+
saleVoucher: null,
|
|
76
|
+
setSaleVoucher: noop,
|
|
77
|
+
saleManualDiscount: "",
|
|
78
|
+
setSaleManualDiscount: noop,
|
|
79
|
+
onSubmit: noop,
|
|
80
|
+
submitLabel: "Save",
|
|
81
|
+
...overrides,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const tinProps = (extra: Partial<TransactionFormProps> = {}) =>
|
|
86
|
+
baseProps({
|
|
87
|
+
payeeTin: "",
|
|
88
|
+
setPayeeTin: vi.fn(),
|
|
89
|
+
payeeAddress: "",
|
|
90
|
+
setPayeeAddress: vi.fn(),
|
|
91
|
+
...extra,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
function mockPayee(record: Record<string, unknown>) {
|
|
95
|
+
vi.stubGlobal(
|
|
96
|
+
"fetch",
|
|
97
|
+
vi.fn(async () => new Response(JSON.stringify(record), { status: 200 })),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
beforeEach(() => vi.unstubAllGlobals());
|
|
102
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
103
|
+
|
|
104
|
+
describe("isValidTin", () => {
|
|
105
|
+
it("accepts 9 or 12 digits with or without dashes", () => {
|
|
106
|
+
expect(isValidTin("123456789")).toBe(true);
|
|
107
|
+
expect(isValidTin("123-456-789")).toBe(true);
|
|
108
|
+
expect(isValidTin("123-456-789-000")).toBe(true);
|
|
109
|
+
expect(isValidTin("123456789000")).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
it("rejects other lengths and non-digits", () => {
|
|
112
|
+
expect(isValidTin("12345678")).toBe(false);
|
|
113
|
+
expect(isValidTin("1234567890")).toBe(false);
|
|
114
|
+
expect(isValidTin("12a-456-789")).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
it("treats blank as valid because the field is optional", () => {
|
|
117
|
+
expect(isValidTin("")).toBe(true);
|
|
118
|
+
expect(isValidTin(" ")).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("TransactionForm payee tax ID", () => {
|
|
123
|
+
it("renders nothing extra when the host does not opt in", async () => {
|
|
124
|
+
mockPayee({ id: 7, name: "Acme Hardware", kind: "vendor", tin: null, address: null });
|
|
125
|
+
const { queryByTestId } = render(() => <TransactionForm {...baseProps()} />);
|
|
126
|
+
await Promise.resolve();
|
|
127
|
+
expect(queryByTestId("form-payee-tin-input")).toBeNull();
|
|
128
|
+
expect(queryByTestId("form-payee-tin-readonly")).toBeNull();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("shows the saved TIN and address read-only and offers no input", async () => {
|
|
132
|
+
mockPayee({
|
|
133
|
+
id: 7,
|
|
134
|
+
name: "Acme Hardware",
|
|
135
|
+
kind: "vendor",
|
|
136
|
+
tin: "123-456-789-00000",
|
|
137
|
+
address: "1 Main St",
|
|
138
|
+
});
|
|
139
|
+
const { findByTestId, queryByTestId } = render(() => <TransactionForm {...tinProps()} />);
|
|
140
|
+
const line = await findByTestId("form-payee-tin-readonly");
|
|
141
|
+
expect(line.textContent).toContain("123-456-789-00000");
|
|
142
|
+
expect(line.textContent).toContain("1 Main St");
|
|
143
|
+
expect(queryByTestId("form-payee-tin-input")).toBeNull();
|
|
144
|
+
expect(queryByTestId("form-payee-address-input")).toBeNull();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("offers TIN and address inputs when the payee has neither", async () => {
|
|
148
|
+
mockPayee({ id: 7, name: "Acme Hardware", kind: "vendor", tin: null, address: null });
|
|
149
|
+
const setPayeeTin = vi.fn();
|
|
150
|
+
const setPayeeAddress = vi.fn();
|
|
151
|
+
const { findByTestId } = render(() => (
|
|
152
|
+
<TransactionForm {...tinProps({ setPayeeTin, setPayeeAddress })} />
|
|
153
|
+
));
|
|
154
|
+
const tin = (await findByTestId("form-payee-tin-input")) as HTMLInputElement;
|
|
155
|
+
fireEvent.input(tin, { target: { value: "123-456-789" } });
|
|
156
|
+
expect(setPayeeTin).toHaveBeenCalledWith("123-456-789");
|
|
157
|
+
const addr = (await findByTestId("form-payee-address-input")) as HTMLInputElement;
|
|
158
|
+
fireEvent.input(addr, { target: { value: "2 Side St" } });
|
|
159
|
+
expect(setPayeeAddress).toHaveBeenCalledWith("2 Side St");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("asks only for the address when the TIN is already saved", async () => {
|
|
163
|
+
mockPayee({ id: 7, name: "Acme Hardware", kind: "vendor", tin: "123456789", address: "" });
|
|
164
|
+
const { findByTestId, queryByTestId } = render(() => <TransactionForm {...tinProps()} />);
|
|
165
|
+
await findByTestId("form-payee-address-input");
|
|
166
|
+
expect(queryByTestId("form-payee-tin-input")).toBeNull();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("flags a malformed TIN", async () => {
|
|
170
|
+
mockPayee({ id: 7, name: "Acme Hardware", kind: "vendor", tin: null, address: null });
|
|
171
|
+
const { findByTestId, queryByTestId } = render(() => (
|
|
172
|
+
<TransactionForm {...tinProps({ payeeTin: "12-34" })} />
|
|
173
|
+
));
|
|
174
|
+
await findByTestId("form-payee-tin-input");
|
|
175
|
+
expect(queryByTestId("form-payee-tin-error")).toBeTruthy();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("does not flag a valid TIN", async () => {
|
|
179
|
+
mockPayee({ id: 7, name: "Acme Hardware", kind: "vendor", tin: null, address: null });
|
|
180
|
+
const { findByTestId, queryByTestId } = render(() => (
|
|
181
|
+
<TransactionForm {...tinProps({ payeeTin: "123-456-789" })} />
|
|
182
|
+
));
|
|
183
|
+
await findByTestId("form-payee-tin-input");
|
|
184
|
+
expect(queryByTestId("form-payee-tin-error")).toBeNull();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("warns, without blocking, when withholding is on and no TIN is known", async () => {
|
|
188
|
+
mockPayee({ id: 7, name: "Acme Hardware", kind: "vendor", tin: null, address: null });
|
|
189
|
+
const { findByTestId } = render(() => <TransactionForm {...tinProps({ hasEwt: true })} />);
|
|
190
|
+
const warn = await findByTestId("form-payee-tin-ewt-warning");
|
|
191
|
+
expect(warn.textContent).toMatch(/TIN/);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("drops the warning once a TIN is typed or already saved", async () => {
|
|
195
|
+
mockPayee({ id: 7, name: "Acme Hardware", kind: "vendor", tin: null, address: null });
|
|
196
|
+
const typed = render(() => (
|
|
197
|
+
<TransactionForm {...tinProps({ hasEwt: true, payeeTin: "123-456-789" })} />
|
|
198
|
+
));
|
|
199
|
+
await typed.findByTestId("form-payee-tin-input");
|
|
200
|
+
expect(typed.queryByTestId("form-payee-tin-ewt-warning")).toBeNull();
|
|
201
|
+
typed.unmount();
|
|
202
|
+
|
|
203
|
+
mockPayee({ id: 7, name: "Acme Hardware", kind: "vendor", tin: "123456789", address: "x" });
|
|
204
|
+
const saved = render(() => <TransactionForm {...tinProps({ hasEwt: true })} />);
|
|
205
|
+
await saved.findByTestId("form-payee-tin-readonly");
|
|
206
|
+
expect(saved.queryByTestId("form-payee-tin-ewt-warning")).toBeNull();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("shows nothing before a payee is chosen", async () => {
|
|
210
|
+
mockPayee({});
|
|
211
|
+
const { queryByTestId } = render(() => (
|
|
212
|
+
<TransactionForm {...tinProps({ payee: "", payeeId: null, hasEwt: true })} />
|
|
213
|
+
));
|
|
214
|
+
await waitFor(() => expect(queryByTestId("form-payee-tin-input")).toBeNull());
|
|
215
|
+
expect(queryByTestId("form-payee-tin-ewt-warning")).toBeNull();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("stays out of sales", async () => {
|
|
219
|
+
mockPayee({ id: 7, name: "Acme Hardware", kind: "customer", tin: null, address: null });
|
|
220
|
+
const { queryByTestId } = render(() => <TransactionForm {...tinProps({ category: "sale" })} />);
|
|
221
|
+
await Promise.resolve();
|
|
222
|
+
expect(queryByTestId("form-payee-tin-input")).toBeNull();
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -25,6 +25,7 @@ import TransactionAccountFields from "./TransactionAccountFields";
|
|
|
25
25
|
import TransactionPayableFields from "./TransactionPayableFields";
|
|
26
26
|
import ComboBox from "./ComboBox";
|
|
27
27
|
import type { ClientOption, PayeeOption, PayeeKind } from "./picker-types";
|
|
28
|
+
import { isValidTin } from "./payee-tin";
|
|
28
29
|
import VoucherPicker, { type VoucherOption } from "./VoucherPicker";
|
|
29
30
|
import MentionTextarea from "./MentionTextarea";
|
|
30
31
|
import SearchableSelect from "./SearchableSelect";
|
|
@@ -105,6 +106,13 @@ async function createPayee(
|
|
|
105
106
|
return (await res.json()) as PayeeOption;
|
|
106
107
|
}
|
|
107
108
|
|
|
109
|
+
async function loadPayee(id: number): Promise<PayeeOption | null> {
|
|
110
|
+
const r = await fetch(`/api/payees/${id}`, { credentials: "include" });
|
|
111
|
+
if (!r.ok) return null;
|
|
112
|
+
const json = (await r.json()) as PayeeOption & { data?: PayeeOption };
|
|
113
|
+
return json.data ?? json;
|
|
114
|
+
}
|
|
115
|
+
|
|
108
116
|
function payeeSecondary(p: PayeeOption): string | null {
|
|
109
117
|
if (!p.default_subcategory && p.kind === "vendor") return null;
|
|
110
118
|
return (
|
|
@@ -248,6 +256,13 @@ export interface TransactionFormProps {
|
|
|
248
256
|
setPayee: (v: string) => void;
|
|
249
257
|
payeeId: number | null;
|
|
250
258
|
setPayeeId: (v: number | null) => void;
|
|
259
|
+
/** Opt in to tax-ID capture for the picked payee by supplying `setPayeeTin`.
|
|
260
|
+
* The form shows the saved TIN/address, or inputs when they are missing, and
|
|
261
|
+
* reports typed values here; the host decides when and how to save them. */
|
|
262
|
+
payeeTin?: string;
|
|
263
|
+
setPayeeTin?: (v: string) => void;
|
|
264
|
+
payeeAddress?: string;
|
|
265
|
+
setPayeeAddress?: (v: string) => void;
|
|
251
266
|
refNumber: string;
|
|
252
267
|
setRefNumber: (v: string) => void;
|
|
253
268
|
taxType: string;
|
|
@@ -360,6 +375,27 @@ export default function TransactionForm(props: TransactionFormProps) {
|
|
|
360
375
|
setSelectedPayee(null);
|
|
361
376
|
}
|
|
362
377
|
});
|
|
378
|
+
// A payee picked from search already carries its tax ID; one restored from a
|
|
379
|
+
// saved row does not, so load it before deciding what to show.
|
|
380
|
+
createEffect(() => {
|
|
381
|
+
const sel = selectedPayee();
|
|
382
|
+
if (!props.setPayeeTin || !sel || sel.tin !== undefined) return;
|
|
383
|
+
const id = sel.id;
|
|
384
|
+
loadPayee(id)
|
|
385
|
+
.then((rec) => {
|
|
386
|
+
const cur = selectedPayee();
|
|
387
|
+
if (!rec || !cur || cur.id !== id) return;
|
|
388
|
+
setSelectedPayee({ ...cur, tin: rec.tin ?? null, address: rec.address ?? null });
|
|
389
|
+
})
|
|
390
|
+
.catch(() => {});
|
|
391
|
+
});
|
|
392
|
+
const taxPayee = () =>
|
|
393
|
+
props.setPayeeTin && props.category !== "sale" && catConfig().showPayee
|
|
394
|
+
? selectedPayee()
|
|
395
|
+
: null;
|
|
396
|
+
const savedTin = () => !!taxPayee()?.tin?.trim();
|
|
397
|
+
const savedAddress = () => !!taxPayee()?.address?.trim();
|
|
398
|
+
const tinKnown = () => taxPayee()?.tin !== undefined;
|
|
363
399
|
let dragCounter = 0;
|
|
364
400
|
let formFileInput: HTMLInputElement | undefined;
|
|
365
401
|
|
|
@@ -756,6 +792,8 @@ export default function TransactionForm(props: TransactionFormProps) {
|
|
|
756
792
|
setSelectedPayee(p);
|
|
757
793
|
props.setPayee(p ? p.name : "");
|
|
758
794
|
props.setPayeeId(p ? p.id : null);
|
|
795
|
+
props.setPayeeTin?.("");
|
|
796
|
+
props.setPayeeAddress?.("");
|
|
759
797
|
}}
|
|
760
798
|
/>
|
|
761
799
|
</FormField>
|
|
@@ -774,6 +812,62 @@ export default function TransactionForm(props: TransactionFormProps) {
|
|
|
774
812
|
</Show>
|
|
775
813
|
</FormField>
|
|
776
814
|
</div>
|
|
815
|
+
<Show when={taxPayee() && tinKnown()}>
|
|
816
|
+
<div class="space-y-2" data-testid="form-payee-tax-id">
|
|
817
|
+
<Show when={savedTin()}>
|
|
818
|
+
<p
|
|
819
|
+
data-testid="form-payee-tin-readonly"
|
|
820
|
+
class="text-xs text-[var(--ks-fg-subtle,#71717a)]"
|
|
821
|
+
>
|
|
822
|
+
TIN {taxPayee()!.tin}
|
|
823
|
+
{savedAddress() ? ` · ${taxPayee()!.address}` : ""}
|
|
824
|
+
</p>
|
|
825
|
+
</Show>
|
|
826
|
+
<div class="grid grid-cols-2 gap-4">
|
|
827
|
+
<Show when={!savedTin()}>
|
|
828
|
+
<FormField label="TIN (optional)">
|
|
829
|
+
<input
|
|
830
|
+
type="text"
|
|
831
|
+
inputmode="numeric"
|
|
832
|
+
data-testid="form-payee-tin-input"
|
|
833
|
+
value={props.payeeTin ?? ""}
|
|
834
|
+
onInput={(e) => props.setPayeeTin!(e.currentTarget.value)}
|
|
835
|
+
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)]"
|
|
836
|
+
placeholder="000-000-000-000"
|
|
837
|
+
/>
|
|
838
|
+
<Show when={!isValidTin(props.payeeTin ?? "")}>
|
|
839
|
+
<p
|
|
840
|
+
data-testid="form-payee-tin-error"
|
|
841
|
+
class="mt-1 text-[10px] text-[var(--ks-danger,#ef4444)]"
|
|
842
|
+
>
|
|
843
|
+
A TIN has 9 digits, or 12 with the branch code.
|
|
844
|
+
</p>
|
|
845
|
+
</Show>
|
|
846
|
+
</FormField>
|
|
847
|
+
</Show>
|
|
848
|
+
<Show when={!savedAddress() && props.setPayeeAddress}>
|
|
849
|
+
<FormField label="Address (optional)">
|
|
850
|
+
<input
|
|
851
|
+
type="text"
|
|
852
|
+
data-testid="form-payee-address-input"
|
|
853
|
+
value={props.payeeAddress ?? ""}
|
|
854
|
+
onInput={(e) => props.setPayeeAddress!(e.currentTarget.value)}
|
|
855
|
+
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)]"
|
|
856
|
+
placeholder="Printed on the invoice"
|
|
857
|
+
/>
|
|
858
|
+
</FormField>
|
|
859
|
+
</Show>
|
|
860
|
+
</div>
|
|
861
|
+
<Show when={props.hasEwt && !savedTin() && !(props.payeeTin ?? "").trim()}>
|
|
862
|
+
<p
|
|
863
|
+
data-testid="form-payee-tin-ewt-warning"
|
|
864
|
+
class="text-[11px] text-[var(--ks-warning,#f59e0b)]"
|
|
865
|
+
>
|
|
866
|
+
Withholding needs the payee's TIN for the 2307 certificate. You can save without it.
|
|
867
|
+
</p>
|
|
868
|
+
</Show>
|
|
869
|
+
</div>
|
|
870
|
+
</Show>
|
|
777
871
|
</Show>
|
|
778
872
|
<Show when={!catConfig().showPayee}>
|
|
779
873
|
<FormField label="Reference #">
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Tax-ID helpers shared by the transaction form and its hosts, so both sides
|
|
2
|
+
// apply the same rule before a TIN is saved.
|
|
3
|
+
|
|
4
|
+
const TIN_PATTERN = /^\d{9}(\d{3})?$/;
|
|
5
|
+
|
|
6
|
+
/** Trims the input and drops dashes and spaces: "123-456-789" -> "123456789". */
|
|
7
|
+
export function normalizeTin(value: string): string {
|
|
8
|
+
return value.replace(/[\s-]/g, "");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** A TIN is 9 digits, or 12 with the branch code. Blank is valid (optional). */
|
|
12
|
+
export function isValidTin(value: string): boolean {
|
|
13
|
+
const digits = normalizeTin(value);
|
|
14
|
+
return digits === "" || TIN_PATTERN.test(digits);
|
|
15
|
+
}
|
|
@@ -14,6 +14,9 @@ export interface PayeeOption {
|
|
|
14
14
|
kind: PayeeKind;
|
|
15
15
|
default_subcategory?: string | null;
|
|
16
16
|
notes?: string | null;
|
|
17
|
+
/** Tax ID on record. `undefined` means not loaded yet; `null`/"" means none. */
|
|
18
|
+
tin?: string | null;
|
|
19
|
+
address?: string | null;
|
|
17
20
|
is_active?: boolean;
|
|
18
21
|
}
|
|
19
22
|
|
package/src/index.ts
CHANGED
|
@@ -212,6 +212,7 @@ export {
|
|
|
212
212
|
// Shared domain option shapes for the common pickers, decoupled from any
|
|
213
213
|
// component (still imported across transactions / counter / payees).
|
|
214
214
|
export type { ClientOption, PayeeOption, PayeeKind } from "./components/composite/picker-types";
|
|
215
|
+
export { isValidTin, normalizeTin } from "./components/composite/payee-tin";
|
|
215
216
|
|
|
216
217
|
export { default as VoucherPicker, calculateDiscount } from "./components/composite/VoucherPicker";
|
|
217
218
|
export type { VoucherOption } from "./components/composite/VoucherPicker";
|