@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.
- package/package.json +1 -1
- package/src/components/base/Tooltip.tsx +12 -1
- package/src/components/composite/AccountRadioPicker.tsx +120 -0
- package/src/components/composite/FormAdvancedSection.tsx +366 -0
- package/src/components/composite/SalesBodyEditor.tsx +418 -0
- package/src/components/composite/TransactionForm.tsx +1053 -0
- package/src/components/composite/TransferAccountsPicker.tsx +291 -0
- package/src/components/composite/TransferFeeChip.tsx +34 -0
- package/src/index.ts +29 -0
|
@@ -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
|
|