@kahitsan/ksui 0.31.1 → 0.33.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.31.1",
3
+ "version": "0.33.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",
@@ -0,0 +1,120 @@
1
+ import { createEffect, Show, For } from "solid-js";
2
+ import AccountAvatar from "../base/AccountAvatar";
3
+ import type { PaymentAccountOption } from "./PaymentAccountPicker";
4
+
5
+ export interface AccountRadioPickerProps {
6
+ accounts: PaymentAccountOption[];
7
+ value: string;
8
+ onChange: (v: string) => void;
9
+ ariaLabel: string;
10
+ excludeId?: string;
11
+ autoDefault?: boolean;
12
+ }
13
+
14
+ export default function AccountRadioPicker(props: AccountRadioPickerProps) {
15
+ const visible = () =>
16
+ props.accounts.filter(
17
+ (a) => !props.excludeId || a.id.toString() !== props.excludeId
18
+ );
19
+
20
+ createEffect(() => {
21
+ if (props.autoDefault === false) return;
22
+ if (props.value) return;
23
+ const first = visible()[0];
24
+ if (first) props.onChange(first.id.toString());
25
+ });
26
+
27
+ const buttonRefs: (HTMLButtonElement | undefined)[] = [];
28
+
29
+ const currentIndex = () => {
30
+ const list = visible();
31
+ const i = list.findIndex((a) => a.id.toString() === props.value);
32
+ return i >= 0 ? i : 0;
33
+ };
34
+
35
+ const selectByIndex = (idx: number) => {
36
+ const list = visible();
37
+ if (list.length === 0) return;
38
+ const wrapped = ((idx % list.length) + list.length) % list.length;
39
+ props.onChange(list[wrapped].id.toString());
40
+ buttonRefs[wrapped]?.focus();
41
+ };
42
+
43
+ const onKeyDown = (e: KeyboardEvent) => {
44
+ switch (e.key) {
45
+ case "ArrowRight":
46
+ case "ArrowDown":
47
+ e.preventDefault();
48
+ selectByIndex(currentIndex() + 1);
49
+ break;
50
+ case "ArrowLeft":
51
+ case "ArrowUp":
52
+ e.preventDefault();
53
+ selectByIndex(currentIndex() - 1);
54
+ break;
55
+ case "Home":
56
+ e.preventDefault();
57
+ selectByIndex(0);
58
+ break;
59
+ case "End":
60
+ e.preventDefault();
61
+ selectByIndex(visible().length - 1);
62
+ break;
63
+ }
64
+ };
65
+
66
+ return (
67
+ <Show
68
+ when={props.accounts.length > 0}
69
+ fallback={
70
+ <input
71
+ type="number"
72
+ value={props.value}
73
+ onInput={(e) => props.onChange(e.currentTarget.value)}
74
+ aria-label={props.ariaLabel}
75
+ placeholder="Account ID (financial-accounts module unavailable)"
76
+ class="w-full bg-zinc-900/60 border border-zinc-800/60 px-3 py-3 text-sm text-zinc-200 ks-hud-clip-button focus:outline-none focus:border-amber-500/50"
77
+ />
78
+ }
79
+ >
80
+ <div
81
+ role="radiogroup"
82
+ aria-label={props.ariaLabel}
83
+ tabIndex={-1}
84
+ class="grid max-sm:grid-cols-2 sm:grid-cols-3 gap-2"
85
+ onKeyDown={onKeyDown}
86
+ >
87
+ <For each={visible()}>
88
+ {(a, i) => {
89
+ const selected = () => props.value === a.id.toString();
90
+ const isTabStop = () => selected() || (!props.value && i() === 0);
91
+ return (
92
+ <button
93
+ ref={(el) => (buttonRefs[i()] = el)}
94
+ type="button"
95
+ role="radio"
96
+ aria-checked={selected()}
97
+ tabIndex={isTabStop() ? 0 : -1}
98
+ onClick={() => props.onChange(a.id.toString())}
99
+ class="group flex items-center gap-2 rounded-lg border px-3 py-3 text-left text-sm transition-colors cursor-pointer ks-hud-clip-top-left-bottom-right"
100
+ classList={{
101
+ "border-amber-500/50 bg-amber-600/10 text-amber-300":
102
+ selected(),
103
+ "border-zinc-700 bg-zinc-800/50 text-zinc-300 hover:border-zinc-600 hover:bg-zinc-800":
104
+ !selected(),
105
+ }}
106
+ >
107
+ <AccountAvatar
108
+ account={a}
109
+ size={24}
110
+ iconClass={selected() ? "text-amber-300" : "text-zinc-400"}
111
+ />
112
+ <span class="truncate">{a.name}</span>
113
+ </button>
114
+ );
115
+ }}
116
+ </For>
117
+ </div>
118
+ </Show>
119
+ );
120
+ }
@@ -0,0 +1,366 @@
1
+ // The advanced-fields container of the transaction create/edit form: the Tax
2
+ // type 4-way toggle + VAT breakdown preview, the EWT checkbox/rate/preview, and
3
+ // the full Private-transaction panel (toggle, share-with-role buttons, per-member
4
+ // checkbox list). Carved verbatim out of TransactionForm; the form still owns the
5
+ // `viewMode === "advanced"` <Show> gate, so toggle behavior is unchanged. Props
6
+ // are a verbatim subset of TransactionFormProps.
7
+
8
+ import { For, Show } from "solid-js";
9
+ import Lock from "lucide-solid/icons/lock";
10
+ import FormField from "../base/FormField";
11
+ import SegmentedFilter from "../base/SegmentedFilter";
12
+ import { formatPHP } from "../../utils/formatPHP";
13
+ import type { TransactionOrgMember, TransactionShareableRole } from "./TransactionForm";
14
+
15
+ export interface FormAdvancedSectionProps {
16
+ amount: string;
17
+ category: string;
18
+ taxType: string;
19
+ setTaxType: (v: string) => void;
20
+ hasEwt: boolean;
21
+ setHasEwt: (v: boolean) => void;
22
+ ewtRate: string;
23
+ setEwtRate: (v: string) => void;
24
+ isPrivate: boolean;
25
+ setIsPrivate: (v: boolean) => void;
26
+ sharedWith: string[];
27
+ setSharedWith: (v: string[]) => void;
28
+ sharedRoleCodes: string[];
29
+ setSharedRoleCodes: (v: string[]) => void;
30
+ orgMembers: TransactionOrgMember[];
31
+ shareableRoles: TransactionShareableRole[];
32
+ canShare: boolean;
33
+ }
34
+
35
+ export default function FormAdvancedSection(props: FormAdvancedSectionProps) {
36
+ return (
37
+ <div
38
+ data-testid="advanced-fields-container"
39
+ class="rounded-lg border border-zinc-700/60 bg-zinc-900/40 p-3 space-y-3"
40
+ >
41
+ <div class="flex items-center gap-2 text-[10px] uppercase tracking-widest text-zinc-400 font-semibold">
42
+ <span>Advanced</span>
43
+ </div>
44
+
45
+ <div class="rounded-lg border border-zinc-800/50 bg-zinc-950/40 p-3">
46
+ <FormField label="Tax">
47
+ <SegmentedFilter
48
+ options={[
49
+ { value: "vat_inclusive", label: "VAT Incl." },
50
+ { value: "vat_exclusive", label: "VAT Excl." },
51
+ { value: "vat_exempt", label: "Exempt" },
52
+ { value: "non_vat", label: "Non-VAT" },
53
+ ]}
54
+ value={props.taxType}
55
+ onChange={props.setTaxType}
56
+ />
57
+ </FormField>
58
+ <Show
59
+ when={
60
+ props.amount &&
61
+ parseFloat(props.amount) > 0 &&
62
+ (props.taxType === "vat_inclusive" ||
63
+ props.taxType === "vat_exclusive")
64
+ }
65
+ >
66
+ {(() => {
67
+ const amt = parseFloat(props.amount);
68
+ const sub =
69
+ props.taxType === "vat_inclusive"
70
+ ? Math.round((amt / 1.12) * 100) / 100
71
+ : amt;
72
+ const vat =
73
+ props.taxType === "vat_inclusive"
74
+ ? Math.round((amt - sub) * 100) / 100
75
+ : Math.round(amt * 0.12 * 100) / 100;
76
+ const total = props.taxType === "vat_exclusive" ? sub + vat : amt;
77
+ return (
78
+ <div class="mt-2 text-xs text-zinc-500 space-y-0.5 border-t border-zinc-800/50 pt-2">
79
+ <div class="flex justify-between">
80
+ <span>VATtable Sales</span>
81
+ <span>{formatPHP(sub)}</span>
82
+ </div>
83
+ <div class="flex justify-between">
84
+ <span>VAT (12%)</span>
85
+ <span>{formatPHP(vat)}</span>
86
+ </div>
87
+ <div class="flex justify-between font-medium text-zinc-300">
88
+ <span>Total</span>
89
+ <span>{formatPHP(total)}</span>
90
+ </div>
91
+ </div>
92
+ );
93
+ })()}
94
+ </Show>
95
+
96
+ <Show
97
+ when={props.category === "expense" || props.category === "payable"}
98
+ >
99
+ <div class="mt-3 border-t border-zinc-800/50 pt-3">
100
+ <label class="flex items-center gap-2 text-xs text-zinc-300 cursor-pointer select-none">
101
+ <input
102
+ type="checkbox"
103
+ checked={props.hasEwt}
104
+ onChange={(e) => props.setHasEwt(e.currentTarget.checked)}
105
+ class="h-4 w-4 accent-amber-500 cursor-pointer"
106
+ />
107
+ <span>Has Expanded Withholding Tax</span>
108
+ </label>
109
+ <Show when={props.hasEwt}>
110
+ <div class="mt-2 space-y-2">
111
+ <FormField label="EWT rate (%)">
112
+ <input
113
+ type="number"
114
+ step="0.01"
115
+ min="0.01"
116
+ max="100"
117
+ value={props.ewtRate}
118
+ onInput={(e) => props.setEwtRate(e.currentTarget.value)}
119
+ class="w-full px-3 py-2 bg-zinc-900/50 border border-zinc-800 text-zinc-100 text-sm rounded-md focus:outline-none focus:border-amber-500/50"
120
+ placeholder="e.g. 1, 2, 5, 10, 15"
121
+ />
122
+ </FormField>
123
+ <Show
124
+ when={
125
+ props.amount &&
126
+ parseFloat(props.amount) > 0 &&
127
+ props.ewtRate &&
128
+ parseFloat(props.ewtRate) > 0
129
+ }
130
+ >
131
+ {(() => {
132
+ const amt = parseFloat(props.amount);
133
+ const rate = parseFloat(props.ewtRate);
134
+ const ewt = Math.round(amt * rate) / 100;
135
+ return (
136
+ <div class="text-xs text-zinc-500 border-t border-zinc-800/50 pt-2">
137
+ <div class="flex justify-between">
138
+ <span>EWT ({rate}%)</span>
139
+ <span>{formatPHP(ewt)}</span>
140
+ </div>
141
+ </div>
142
+ );
143
+ })()}
144
+ </Show>
145
+ </div>
146
+ </Show>
147
+ </div>
148
+ </Show>
149
+ </div>
150
+
151
+ <div
152
+ class="rounded-lg border border-zinc-800/50 bg-zinc-950/40 p-4 relative"
153
+ classList={{ "opacity-60": !props.canShare }}
154
+ title={
155
+ props.canShare
156
+ ? undefined
157
+ : "Sharing private transactions requires the members.list_basic permission."
158
+ }
159
+ >
160
+ <div class="flex items-center justify-between min-h-[44px]">
161
+ <div class="flex items-center gap-2">
162
+ <Lock size={14} class="text-zinc-500" />
163
+ <div>
164
+ <span class="text-sm text-zinc-300">Private transaction</span>
165
+ <p class="text-[10px] text-zinc-600">
166
+ {props.canShare
167
+ ? "Hidden from others unless shared"
168
+ : "Locked — needs members.list_basic permission"}
169
+ </p>
170
+ </div>
171
+ </div>
172
+ <button
173
+ type="button"
174
+ disabled={!props.canShare}
175
+ aria-label="Toggle private transaction"
176
+ onClick={() => {
177
+ if (!props.canShare) return;
178
+ props.setIsPrivate(!props.isPrivate);
179
+ }}
180
+ class="ks-theme-toggle shrink-0"
181
+ classList={{ "cursor-not-allowed": !props.canShare }}
182
+ >
183
+ <span
184
+ class="ks-theme-toggle-track"
185
+ data-active={props.isPrivate ? "" : undefined}
186
+ >
187
+ <span class="ks-theme-toggle-icon ks-theme-toggle-icon-moon">
188
+ <Lock size={12} />
189
+ </span>
190
+ <span class="ks-theme-toggle-icon ks-theme-toggle-icon-sun">
191
+ <Lock size={12} />
192
+ </span>
193
+ </span>
194
+ </button>
195
+ </div>
196
+
197
+ <Show when={props.isPrivate && props.canShare}>
198
+ <div class="mt-4 pt-3 border-t border-zinc-800/50">
199
+ <p class="text-[10px] text-zinc-500 mb-3">
200
+ Always visible to:{" "}
201
+ <span class="text-zinc-400">
202
+ you (creator), org admins, superusers
203
+ </span>
204
+ </p>
205
+
206
+ <span class="text-xs text-zinc-500 block mb-2">
207
+ Share with role
208
+ </span>
209
+ <div class="flex gap-2 mb-3 flex-wrap">
210
+ <For each={props.shareableRoles}>
211
+ {(role) => {
212
+ const members = () =>
213
+ Array.isArray(props.orgMembers) ? props.orgMembers : [];
214
+ const membersInRole = () =>
215
+ members().filter((m) => m.role === role.code);
216
+ const selected = () =>
217
+ props.sharedRoleCodes.includes(role.code);
218
+ return (
219
+ <button
220
+ type="button"
221
+ onClick={() => {
222
+ if (selected()) {
223
+ props.setSharedRoleCodes(
224
+ props.sharedRoleCodes.filter((c) => c !== role.code)
225
+ );
226
+ } else {
227
+ const roleMemberIds = membersInRole().map(
228
+ (m) => m.user_id
229
+ );
230
+ props.setSharedWith(
231
+ props.sharedWith.filter(
232
+ (id) => !roleMemberIds.includes(id)
233
+ )
234
+ );
235
+ props.setSharedRoleCodes([
236
+ ...props.sharedRoleCodes,
237
+ role.code,
238
+ ]);
239
+ }
240
+ }}
241
+ class="px-3 py-2 text-xs rounded-lg border cursor-pointer min-h-[36px] capitalize transition-colors active:opacity-80"
242
+ classList={{
243
+ "border-amber-500/40 bg-amber-500/10 text-amber-400":
244
+ selected(),
245
+ "border-zinc-700 bg-zinc-800/50 text-zinc-400 hover:border-zinc-600":
246
+ !selected(),
247
+ }}
248
+ >
249
+ All {role.label}s
250
+ <Show when={membersInRole().length > 0}>
251
+ <span class="text-zinc-600 ml-1">
252
+ ({membersInRole().length})
253
+ </span>
254
+ </Show>
255
+ </button>
256
+ );
257
+ }}
258
+ </For>
259
+ </div>
260
+
261
+ <span class="text-xs text-zinc-500 block mb-2">
262
+ Or select people
263
+ </span>
264
+ <div class="space-y-0.5 max-h-[150px] overflow-y-auto">
265
+ <For
266
+ each={(Array.isArray(props.orgMembers)
267
+ ? props.orgMembers
268
+ : []
269
+ ).filter((m) => m.role !== "admin")}
270
+ >
271
+ {(m) => {
272
+ const coveringRole = () =>
273
+ props.shareableRoles.find(
274
+ (r) =>
275
+ r.code === m.role &&
276
+ props.sharedRoleCodes.includes(r.code)
277
+ );
278
+ const checked = () =>
279
+ props.sharedWith.includes(m.user_id) || !!coveringRole();
280
+ return (
281
+ <label
282
+ class="flex items-center gap-3 text-sm py-2 px-2 rounded-lg min-h-[40px] transition-colors"
283
+ classList={{
284
+ "text-zinc-300 cursor-pointer hover:bg-zinc-800/30 active:bg-zinc-800/50":
285
+ !coveringRole(),
286
+ "text-zinc-500 cursor-not-allowed bg-zinc-900/40":
287
+ !!coveringRole(),
288
+ }}
289
+ >
290
+ <div
291
+ class="w-5 h-5 rounded border-2 flex items-center justify-center shrink-0 transition-colors"
292
+ classList={{
293
+ "border-amber-500 bg-amber-500":
294
+ checked() && !coveringRole(),
295
+ "border-amber-500/40 bg-amber-500/40":
296
+ checked() && !!coveringRole(),
297
+ "border-zinc-600 bg-transparent": !checked(),
298
+ }}
299
+ >
300
+ <Show when={checked()}>
301
+ <svg
302
+ class="w-3 h-3 text-zinc-900"
303
+ fill="none"
304
+ viewBox="0 0 24 24"
305
+ stroke="currentColor"
306
+ stroke-width="3"
307
+ >
308
+ <path
309
+ stroke-linecap="round"
310
+ stroke-linejoin="round"
311
+ d="M5 13l4 4L19 7"
312
+ />
313
+ </svg>
314
+ </Show>
315
+ </div>
316
+ <input
317
+ type="checkbox"
318
+ checked={checked()}
319
+ disabled={!!coveringRole()}
320
+ onChange={(e) => {
321
+ if (coveringRole()) return;
322
+ if (e.target.checked) {
323
+ props.setSharedWith([
324
+ ...props.sharedWith,
325
+ m.user_id,
326
+ ]);
327
+ } else {
328
+ props.setSharedWith(
329
+ props.sharedWith.filter((id) => id !== m.user_id)
330
+ );
331
+ }
332
+ }}
333
+ class="sr-only"
334
+ />
335
+ <span class="flex-1">{m.name}</span>
336
+ <Show
337
+ when={coveringRole()}
338
+ fallback={
339
+ <span class="text-[10px] text-zinc-600 capitalize">
340
+ {m.role}
341
+ </span>
342
+ }
343
+ >
344
+ <span class="text-[10px] text-amber-500/70">
345
+ via All {coveringRole()!.label}s
346
+ </span>
347
+ </Show>
348
+ </label>
349
+ );
350
+ }}
351
+ </For>
352
+ <Show
353
+ when={
354
+ !Array.isArray(props.orgMembers) ||
355
+ props.orgMembers.length === 0
356
+ }
357
+ >
358
+ <p class="text-xs text-zinc-600 py-2">Loading members...</p>
359
+ </Show>
360
+ </div>
361
+ </div>
362
+ </Show>
363
+ </div>
364
+ </div>
365
+ );
366
+ }