@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,291 @@
1
+ import { Show, type JSX } from "solid-js";
2
+ import ArrowRightLeft from "lucide-solid/icons/arrow-right-left";
3
+ import Plus from "lucide-solid/icons/plus";
4
+ import AccountAvatar from "../base/AccountAvatar";
5
+ import FormField from "../base/FormField";
6
+ import AccountRadioPicker from "./AccountRadioPicker";
7
+ import type { TransactionAccount } from "./TransactionForm";
8
+ import { formatPHP } from "../../utils/formatPHP";
9
+
10
+ export interface TransferAccountsPickerProps {
11
+ accounts: TransactionAccount[];
12
+ sourceAccount: string;
13
+ setSourceAccount: (v: string) => void;
14
+ destAccount: string;
15
+ setDestAccount: (v: string) => void;
16
+ sourceLabel: string;
17
+ destLabel: string;
18
+ amount: string;
19
+ feeAmount: string;
20
+ feeEnabled: boolean;
21
+ }
22
+
23
+ interface AccountTileProps {
24
+ role: "from" | "to";
25
+ account: TransactionAccount | undefined;
26
+ emptyHint: string;
27
+ delta: number;
28
+ onEditRequest: () => void;
29
+ tileTestId: string;
30
+ clipClass: string;
31
+ }
32
+
33
+ function AccountTile(p: AccountTileProps): JSX.Element {
34
+ const isFilled = () => !!p.account;
35
+ const isFrom = () => p.role === "from";
36
+ return (
37
+ <button
38
+ type="button"
39
+ data-testid={p.tileTestId}
40
+ onClick={p.onEditRequest}
41
+ class={`group flex flex-1 min-w-0 items-center gap-3 border px-4 py-3 text-left transition-colors cursor-pointer ${p.clipClass}`}
42
+ classList={{
43
+ "border-blue-500/40 bg-blue-500/5 hover:bg-blue-500/10":
44
+ isFilled() && isFrom(),
45
+ "border-amber-500/40 bg-amber-500/5 hover:bg-amber-500/10":
46
+ isFilled() && !isFrom(),
47
+ "border-dashed border-zinc-700 bg-zinc-900/40 hover:border-amber-500/50 hover:bg-zinc-900/60":
48
+ !isFilled(),
49
+ }}
50
+ aria-label={
51
+ isFilled()
52
+ ? `${isFrom() ? "Source" : "Destination"}: ${p.account!.name} — tap to change`
53
+ : `Select ${isFrom() ? "source" : "destination"} account`
54
+ }
55
+ >
56
+ <Show
57
+ when={p.account}
58
+ keyed
59
+ fallback={
60
+ <span class="flex h-8 w-8 items-center justify-center text-zinc-500">
61
+ <Plus size={18} />
62
+ </span>
63
+ }
64
+ >
65
+ {(a) => (
66
+ <AccountAvatar
67
+ account={a}
68
+ size={28}
69
+ iconClass={isFrom() ? "text-blue-300" : "text-amber-300"}
70
+ />
71
+ )}
72
+ </Show>
73
+ <div class="flex min-w-0 flex-col">
74
+ <span
75
+ class="text-[10px] font-semibold uppercase tracking-widest"
76
+ classList={{
77
+ "text-blue-300": isFilled() && isFrom(),
78
+ "text-amber-300": isFilled() && !isFrom(),
79
+ "text-zinc-500": !isFilled(),
80
+ }}
81
+ >
82
+ {isFrom() ? "From" : "To"}
83
+ </span>
84
+ <Show
85
+ when={p.account}
86
+ keyed
87
+ fallback={
88
+ <span class="text-sm text-zinc-500">{p.emptyHint}</span>
89
+ }
90
+ >
91
+ {(a) => (
92
+ <>
93
+ <span class="truncate text-sm font-semibold text-zinc-100">
94
+ {a.name}
95
+ </span>
96
+ <Show when={a.balance != null}>
97
+ <span class="flex items-baseline gap-1 text-[11px] tabular-nums text-zinc-400">
98
+ <span class="truncate">{formatPHP(a.balance!)}</span>
99
+ <Show when={p.delta !== 0}>
100
+ <span
101
+ class="whitespace-nowrap font-semibold"
102
+ classList={{
103
+ "text-red-400": p.delta < 0,
104
+ "text-emerald-400": p.delta > 0,
105
+ }}
106
+ data-testid={`transactions-form-transfer-delta-${p.role}`}
107
+ >
108
+ ({p.delta > 0 ? "+" : ""}
109
+ {formatPHP(p.delta)})
110
+ </span>
111
+ </Show>
112
+ </span>
113
+ </Show>
114
+ </>
115
+ )}
116
+ </Show>
117
+ </div>
118
+ </button>
119
+ );
120
+ }
121
+
122
+ export default function TransferAccountsPicker(
123
+ props: TransferAccountsPickerProps
124
+ ) {
125
+ const sourceMeta = () =>
126
+ props.accounts.find((a) => a.id.toString() === props.sourceAccount);
127
+ const destMeta = () =>
128
+ props.accounts.find((a) => a.id.toString() === props.destAccount);
129
+
130
+ const swap = () => {
131
+ const s = props.sourceAccount;
132
+ const d = props.destAccount;
133
+ props.setSourceAccount(d);
134
+ props.setDestAccount(s);
135
+ };
136
+
137
+ const bothFilled = () => !!props.sourceAccount && !!props.destAccount;
138
+
139
+ const amountNum = () => {
140
+ const n = parseFloat(props.amount);
141
+ return Number.isFinite(n) && n > 0 ? n : 0;
142
+ };
143
+ const feeNum = () => {
144
+ if (!props.feeEnabled) return 0;
145
+ const n = parseFloat(props.feeAmount);
146
+ return Number.isFinite(n) && n > 0 ? n : 0;
147
+ };
148
+ const sourceDelta = () => -(amountNum() + feeNum());
149
+ const destDelta = () => amountNum();
150
+
151
+ return (
152
+ <div class="space-y-3">
153
+ <div class="flex items-stretch gap-2 max-sm:flex-col sm:flex-row">
154
+ <AccountTile
155
+ role="from"
156
+ account={sourceMeta()}
157
+ emptyHint="Select source"
158
+ delta={sourceDelta()}
159
+ onEditRequest={() => props.setSourceAccount("")}
160
+ tileTestId="transactions-form-transfer-tile-from"
161
+ clipClass="ks-hud-clip-top-right-bottom-left"
162
+ />
163
+
164
+ <Show
165
+ when={!bothFilled()}
166
+ fallback={
167
+ <button
168
+ type="button"
169
+ data-testid="transactions-form-transfer-swap"
170
+ onClick={swap}
171
+ class="flex h-9 w-9 shrink-0 items-center justify-center self-center border border-zinc-700 bg-zinc-900 text-zinc-300 transition-colors hover:border-amber-500/60 hover:text-amber-300 cursor-pointer ks-hud-clip-button max-sm:rotate-90"
172
+ aria-label="Swap source and destination"
173
+ title="Swap source and destination"
174
+ >
175
+ <ArrowRightLeft size={14} />
176
+ </button>
177
+ }
178
+ >
179
+ <div
180
+ class="shrink-0 self-center max-sm:py-1 sm:px-1"
181
+ aria-hidden="true"
182
+ >
183
+ <svg
184
+ viewBox="0 0 56 14"
185
+ class="max-sm:hidden sm:block h-3.5 w-14"
186
+ >
187
+ <defs>
188
+ <linearGradient
189
+ id="fin-flow-h"
190
+ gradientUnits="userSpaceOnUse"
191
+ x1="0"
192
+ y1="7"
193
+ x2="56"
194
+ y2="7"
195
+ >
196
+ <stop offset="0" stop-color="#3b82f6" />
197
+ <stop offset="1" stop-color="#f59e0b" />
198
+ </linearGradient>
199
+ </defs>
200
+ <line
201
+ x1="0"
202
+ y1="7"
203
+ x2="44"
204
+ y2="7"
205
+ stroke="url(#fin-flow-h)"
206
+ stroke-width="2"
207
+ class="fin-flow-dash"
208
+ />
209
+ <path d="M44 1 L56 7 L44 13 Z" fill="#f59e0b" />
210
+ </svg>
211
+ <svg
212
+ viewBox="0 0 14 40"
213
+ class="max-sm:block sm:hidden h-10 w-3.5"
214
+ >
215
+ <defs>
216
+ <linearGradient
217
+ id="fin-flow-v"
218
+ gradientUnits="userSpaceOnUse"
219
+ x1="7"
220
+ y1="0"
221
+ x2="7"
222
+ y2="40"
223
+ >
224
+ <stop offset="0" stop-color="#3b82f6" />
225
+ <stop offset="1" stop-color="#f59e0b" />
226
+ </linearGradient>
227
+ </defs>
228
+ <line
229
+ x1="7"
230
+ y1="0"
231
+ x2="7"
232
+ y2="30"
233
+ stroke="url(#fin-flow-v)"
234
+ stroke-width="2"
235
+ class="fin-flow-dash"
236
+ />
237
+ <path d="M1 30 L13 30 L7 40 Z" fill="#f59e0b" />
238
+ </svg>
239
+ </div>
240
+ </Show>
241
+
242
+ <AccountTile
243
+ role="to"
244
+ account={destMeta()}
245
+ emptyHint={
246
+ props.sourceAccount ? "Select destination" : "Destination"
247
+ }
248
+ delta={destDelta()}
249
+ onEditRequest={() => props.setDestAccount("")}
250
+ tileTestId="transactions-form-transfer-tile-to"
251
+ clipClass="ks-hud-clip-top-left-bottom-right"
252
+ />
253
+ </div>
254
+
255
+ <Show when={!props.sourceAccount}>
256
+ <div
257
+ class="animate-[fin-slide-fade-down_0.28s_ease-out]"
258
+ data-testid="transactions-form-transfer-source-picker"
259
+ >
260
+ <FormField label={props.sourceLabel}>
261
+ <AccountRadioPicker
262
+ accounts={props.accounts}
263
+ ariaLabel={props.sourceLabel}
264
+ value={props.sourceAccount}
265
+ onChange={props.setSourceAccount}
266
+ excludeId={props.destAccount}
267
+ autoDefault={false}
268
+ />
269
+ </FormField>
270
+ </div>
271
+ </Show>
272
+ <Show when={props.sourceAccount && !props.destAccount}>
273
+ <div
274
+ class="animate-[fin-slide-fade-down_0.28s_ease-out]"
275
+ data-testid="transactions-form-transfer-dest-picker"
276
+ >
277
+ <FormField label={props.destLabel}>
278
+ <AccountRadioPicker
279
+ accounts={props.accounts}
280
+ ariaLabel={props.destLabel}
281
+ value={props.destAccount}
282
+ onChange={props.setDestAccount}
283
+ excludeId={props.sourceAccount}
284
+ autoDefault={false}
285
+ />
286
+ </FormField>
287
+ </div>
288
+ </Show>
289
+ </div>
290
+ );
291
+ }
@@ -0,0 +1,34 @@
1
+ import X from "lucide-solid/icons/x";
2
+
3
+ export interface TransferFeeChipProps {
4
+ enabled: boolean;
5
+ onToggle: () => void;
6
+ }
7
+
8
+ export default function TransferFeeChip(props: TransferFeeChipProps) {
9
+ return (
10
+ <button
11
+ type="button"
12
+ data-testid="transactions-form-transfer-fee-toggle"
13
+ onClick={props.onToggle}
14
+ class="flex items-center gap-1.5 self-center rounded-md border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wider transition-colors cursor-pointer"
15
+ classList={{
16
+ "border-blue-500/50 bg-blue-500/15 text-blue-300 hover:bg-blue-500/25":
17
+ props.enabled,
18
+ "border-zinc-700 bg-zinc-950/60 text-zinc-400 hover:border-blue-500/40 hover:text-blue-300":
19
+ !props.enabled,
20
+ }}
21
+ aria-pressed={props.enabled}
22
+ aria-label={props.enabled ? "Remove transfer fee" : "Add transfer fee"}
23
+ >
24
+ <span>Fees</span>
25
+ <span
26
+ class="inline-flex items-center justify-center"
27
+ classList={{ hidden: !props.enabled }}
28
+ aria-hidden="true"
29
+ >
30
+ <X size={11} />
31
+ </span>
32
+ </button>
33
+ );
34
+ }
package/src/index.ts CHANGED
@@ -109,6 +109,35 @@ export {
109
109
  export { default as PaymentAccountPicker } from "./components/composite/PaymentAccountPicker";
110
110
  export type { PaymentAccountOption } from "./components/composite/PaymentAccountPicker";
111
111
 
112
+ // AccountRadioPicker: a static grid/radiogroup of account buttons fed by an
113
+ // already-fetched accounts prop (vs. PaymentAccountPicker's self-fetching
114
+ // dropdown) -- the payment-source picker used inside TransactionForm.
115
+ export { default as AccountRadioPicker } from "./components/composite/AccountRadioPicker";
116
+ export type { AccountRadioPickerProps } from "./components/composite/AccountRadioPicker";
117
+
118
+ // TransactionForm: the full transaction create/edit form (category picker,
119
+ // SalesBodyEditor, amount/date/payee/attachments, advanced tax/EWT/sharing
120
+ // fields). `simpleMode` hides the Type picker + advanced toggle for a caller
121
+ // that locks `category` to one value. Lifted verbatim from kplugin_finance so
122
+ // any plugin recording a transaction against the shared /api/transactions
123
+ // endpoint can reuse the same form instead of forking it.
124
+ export { default as TransactionForm } from "./components/composite/TransactionForm";
125
+ export type {
126
+ TransactionFormProps,
127
+ TransactionAccount,
128
+ TransactionOrgMember,
129
+ TransactionShareableRole,
130
+ TransactionAttachment,
131
+ } from "./components/composite/TransactionForm";
132
+ export { default as FormAdvancedSection } from "./components/composite/FormAdvancedSection";
133
+ export type { FormAdvancedSectionProps } from "./components/composite/FormAdvancedSection";
134
+ export { default as SalesBodyEditor } from "./components/composite/SalesBodyEditor";
135
+ export type { SalesLine, SalesBodyEditorProps } from "./components/composite/SalesBodyEditor";
136
+ export { default as TransferFeeChip } from "./components/composite/TransferFeeChip";
137
+ export type { TransferFeeChipProps } from "./components/composite/TransferFeeChip";
138
+ export { default as TransferAccountsPicker } from "./components/composite/TransferAccountsPicker";
139
+ export type { TransferAccountsPickerProps } from "./components/composite/TransferAccountsPicker";
140
+
112
141
  // LiveTimer wraps ProgressBar with timer state and elapsed-time display.
113
142
  export { default as LiveTimer, type LiveTimerProps } from "./components/composite/LiveTimer";
114
143