@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,1053 @@
|
|
|
1
|
+
// The transaction create/edit form: category type picker, SalesBodyEditor mount,
|
|
2
|
+
// amount/date/backdate/description, PayeePicker + ref number, subcategory
|
|
3
|
+
// createResource + SearchableSelect, payable-details pane, AccountRadioPicker
|
|
4
|
+
// wiring, notes, attachments drag/drop/paste/camera + pending-file tiles,
|
|
5
|
+
// footer. The advanced fields live in FormAdvancedSection, gated here by the
|
|
6
|
+
// `viewMode === "advanced"` <Show>. `simpleMode` hides the Type picker and the
|
|
7
|
+
// advanced-fields toggle entirely, for a caller that locks `category` to one
|
|
8
|
+
// value and never needs EWT/sharing (e.g. a "record my own expense" surface).
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
createEffect,
|
|
12
|
+
createResource,
|
|
13
|
+
createSignal,
|
|
14
|
+
Show,
|
|
15
|
+
For,
|
|
16
|
+
} from "solid-js";
|
|
17
|
+
import X from "lucide-solid/icons/x";
|
|
18
|
+
import Upload from "lucide-solid/icons/upload";
|
|
19
|
+
import FileIcon from "lucide-solid/icons/file";
|
|
20
|
+
import CalendarDays from "lucide-solid/icons/calendar-days";
|
|
21
|
+
import Paperclip from "lucide-solid/icons/paperclip";
|
|
22
|
+
import Store from "lucide-solid/icons/store";
|
|
23
|
+
import ArrowDownLeft from "lucide-solid/icons/arrow-down-left";
|
|
24
|
+
import ArrowUpRight from "lucide-solid/icons/arrow-up-right";
|
|
25
|
+
import ArrowRightLeft from "lucide-solid/icons/arrow-right-left";
|
|
26
|
+
|
|
27
|
+
import AccountRadioPicker from "./AccountRadioPicker";
|
|
28
|
+
import FormAdvancedSection from "./FormAdvancedSection";
|
|
29
|
+
import SalesBodyEditor, { type SalesLine } from "./SalesBodyEditor";
|
|
30
|
+
import TransferFeeChip from "./TransferFeeChip";
|
|
31
|
+
import TransferAccountsPicker from "./TransferAccountsPicker";
|
|
32
|
+
import ComboBox from "./ComboBox";
|
|
33
|
+
import type { ClientOption, PayeeOption, PayeeKind } from "./picker-types";
|
|
34
|
+
import VoucherPicker, { type VoucherOption } from "./VoucherPicker";
|
|
35
|
+
import MentionTextarea from "./MentionTextarea";
|
|
36
|
+
import SearchableSelect from "./SearchableSelect";
|
|
37
|
+
import type { PaymentAccountOption } from "./PaymentAccountPicker";
|
|
38
|
+
import CameraCapture from "../base/CameraCapture";
|
|
39
|
+
import AddAttachmentTile from "../base/AddAttachmentTile";
|
|
40
|
+
import ExistingAttachmentTile, {
|
|
41
|
+
type ExistingAttachment,
|
|
42
|
+
} from "../base/ExistingAttachmentTile";
|
|
43
|
+
import FormField from "../base/FormField";
|
|
44
|
+
import DatePicker from "../base/DatePicker";
|
|
45
|
+
import Button from "../base/Button";
|
|
46
|
+
import SegmentedFilter from "../base/SegmentedFilter";
|
|
47
|
+
import {
|
|
48
|
+
type PendingFile,
|
|
49
|
+
createPendingFile,
|
|
50
|
+
revokePendingFile,
|
|
51
|
+
} from "../../utils/pending-file";
|
|
52
|
+
|
|
53
|
+
export type TransactionAccount = PaymentAccountOption & {
|
|
54
|
+
balance?: number | string | null;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export interface TransactionOrgMember {
|
|
58
|
+
user_id: string;
|
|
59
|
+
name: string;
|
|
60
|
+
role: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface TransactionShareableRole {
|
|
64
|
+
code: string;
|
|
65
|
+
label: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type TransactionAttachment = ExistingAttachment;
|
|
69
|
+
|
|
70
|
+
// Payee data-wiring for the generic ComboBox engine. Search/create hit the
|
|
71
|
+
// host app's /api/payees endpoint directly; `kind` is "customer" for sales
|
|
72
|
+
// and "vendor" otherwise. Degrades gracefully — a missing payees endpoint
|
|
73
|
+
// surfaces a notice and the free-text fallback (selectedName) still works.
|
|
74
|
+
async function searchPayees(
|
|
75
|
+
query: string,
|
|
76
|
+
kind: PayeeKind
|
|
77
|
+
): Promise<PayeeOption[]> {
|
|
78
|
+
const params = new URLSearchParams({ status: "active", limit: "20", kind });
|
|
79
|
+
if (query) params.set("search", query);
|
|
80
|
+
const r = await fetch(`/api/payees?${params.toString()}`, {
|
|
81
|
+
credentials: "include",
|
|
82
|
+
});
|
|
83
|
+
if (!r.ok) {
|
|
84
|
+
if (r.status === 403) throw new Error("Permission denied");
|
|
85
|
+
if (r.status === 404)
|
|
86
|
+
throw new Error("Payees module isn't available — type a name instead");
|
|
87
|
+
throw new Error("Failed to load");
|
|
88
|
+
}
|
|
89
|
+
const json = (await r.json()) as { data?: PayeeOption[] };
|
|
90
|
+
return json.data ?? [];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function createPayee(
|
|
94
|
+
name: string,
|
|
95
|
+
kind: PayeeKind
|
|
96
|
+
): Promise<PayeeOption> {
|
|
97
|
+
const res = await fetch("/api/payees", {
|
|
98
|
+
method: "POST",
|
|
99
|
+
credentials: "include",
|
|
100
|
+
headers: { "Content-Type": "application/json" },
|
|
101
|
+
body: JSON.stringify({ name, kind }),
|
|
102
|
+
});
|
|
103
|
+
if (!res.ok && res.status !== 200) {
|
|
104
|
+
const body = (await res
|
|
105
|
+
.json()
|
|
106
|
+
.catch(() => ({ error: "Failed to create payee" }))) as {
|
|
107
|
+
error?: string;
|
|
108
|
+
};
|
|
109
|
+
throw new Error(body.error || "Failed to create payee");
|
|
110
|
+
}
|
|
111
|
+
return (await res.json()) as PayeeOption;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function payeeSecondary(p: PayeeOption): string | null {
|
|
115
|
+
if (!p.default_subcategory && p.kind === "vendor") return null;
|
|
116
|
+
return (
|
|
117
|
+
[p.kind === "vendor" ? null : p.kind, p.default_subcategory]
|
|
118
|
+
.filter(Boolean)
|
|
119
|
+
.join(" · ") || null
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
type IconComponent = (props: { size?: number; class?: string }) => import("solid-js").JSX.Element;
|
|
124
|
+
|
|
125
|
+
const CATEGORY_TONE: Record<
|
|
126
|
+
string,
|
|
127
|
+
{ tone: "emerald" | "red" | "blue" | "amber"; icon: IconComponent }
|
|
128
|
+
> = {
|
|
129
|
+
sale: { tone: "emerald", icon: ArrowDownLeft },
|
|
130
|
+
expense: { tone: "red", icon: ArrowUpRight },
|
|
131
|
+
payable: { tone: "amber", icon: CalendarDays },
|
|
132
|
+
business: { tone: "blue", icon: ArrowRightLeft },
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const TONE_CLASSES: Record<
|
|
136
|
+
"emerald" | "red" | "blue" | "amber",
|
|
137
|
+
{ bg: string; text: string; border: string }
|
|
138
|
+
> = {
|
|
139
|
+
emerald: {
|
|
140
|
+
bg: "bg-emerald-500/10",
|
|
141
|
+
text: "text-emerald-400",
|
|
142
|
+
border: "border-emerald-500/30",
|
|
143
|
+
},
|
|
144
|
+
red: { bg: "bg-red-500/10", text: "text-red-400", border: "border-red-500/30" },
|
|
145
|
+
blue: { bg: "bg-blue-500/10", text: "text-blue-400", border: "border-blue-500/30" },
|
|
146
|
+
amber: {
|
|
147
|
+
bg: "bg-amber-500/10",
|
|
148
|
+
text: "text-amber-400",
|
|
149
|
+
border: "border-amber-500/30",
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const PAYABLE_KIND_OPTIONS: { id: string; label: string }[] = [
|
|
154
|
+
{ id: "subscription", label: "Subscription" },
|
|
155
|
+
{ id: "utility", label: "Utility" },
|
|
156
|
+
{ id: "rent", label: "Rent / Lease" },
|
|
157
|
+
{ id: "loan", label: "Loan" },
|
|
158
|
+
{ id: "tax", label: "Tax" },
|
|
159
|
+
{ id: "other", label: "Other" },
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
const PDC_OPTIONS: { id: string; label: string; dot: string }[] = [
|
|
163
|
+
{ id: "issued", label: "PDC issued", dot: "bg-amber-400" },
|
|
164
|
+
{ id: "presented", label: "PDC presented", dot: "bg-blue-400" },
|
|
165
|
+
{ id: "cleared", label: "PDC cleared", dot: "bg-emerald-400" },
|
|
166
|
+
{ id: "bounced", label: "PDC bounced", dot: "bg-red-400" },
|
|
167
|
+
];
|
|
168
|
+
|
|
169
|
+
const CATEGORY_FORM: Record<
|
|
170
|
+
string,
|
|
171
|
+
{
|
|
172
|
+
label: string;
|
|
173
|
+
hint: string;
|
|
174
|
+
descPlaceholder: string;
|
|
175
|
+
accountLabel: string;
|
|
176
|
+
accountHint: string;
|
|
177
|
+
showSecondAccount: boolean;
|
|
178
|
+
secondAccountLabel?: string;
|
|
179
|
+
payeeLabel?: string;
|
|
180
|
+
payeePlaceholder?: string;
|
|
181
|
+
showPayee: boolean;
|
|
182
|
+
}
|
|
183
|
+
> = {
|
|
184
|
+
expense: {
|
|
185
|
+
label: "Expense",
|
|
186
|
+
hint: "Money going out -- paying for supplies, bills, services",
|
|
187
|
+
descPlaceholder: 'What did you pay for? e.g. "Office supplies"',
|
|
188
|
+
accountLabel: "Paid from",
|
|
189
|
+
accountHint: "Which account was used to pay?",
|
|
190
|
+
showSecondAccount: false,
|
|
191
|
+
payeeLabel: "Paid to",
|
|
192
|
+
payeePlaceholder: 'Store or vendor name, e.g. "Jollibee Magsaysay"',
|
|
193
|
+
showPayee: true,
|
|
194
|
+
},
|
|
195
|
+
sale: {
|
|
196
|
+
label: "Income",
|
|
197
|
+
hint: "Money coming in -- payment received from a customer, client, or other source",
|
|
198
|
+
descPlaceholder: 'What came in? e.g. "Day pass - Walk-in"',
|
|
199
|
+
accountLabel: "Received in",
|
|
200
|
+
accountHint: "Where did the payment go?",
|
|
201
|
+
showSecondAccount: false,
|
|
202
|
+
payeeLabel: "Received from",
|
|
203
|
+
payeePlaceholder: "Customer name (optional)",
|
|
204
|
+
showPayee: true,
|
|
205
|
+
},
|
|
206
|
+
business: {
|
|
207
|
+
label: "Transfer",
|
|
208
|
+
hint: "Moving money between your own accounts",
|
|
209
|
+
descPlaceholder: 'Why? e.g. "Replenish petty cash from bank"',
|
|
210
|
+
accountLabel: "From account",
|
|
211
|
+
accountHint: "",
|
|
212
|
+
showSecondAccount: true,
|
|
213
|
+
secondAccountLabel: "To account",
|
|
214
|
+
showPayee: false,
|
|
215
|
+
},
|
|
216
|
+
payable: {
|
|
217
|
+
label: "Payable",
|
|
218
|
+
hint: "Recurring or scheduled payment -- subscription, utility, rent, loan, tax",
|
|
219
|
+
descPlaceholder: 'What is it? e.g. "Office rent -- May"',
|
|
220
|
+
accountLabel: "Funding account",
|
|
221
|
+
accountHint: "Which account will be debited when this is paid?",
|
|
222
|
+
showSecondAccount: false,
|
|
223
|
+
payeeLabel: "Payable to",
|
|
224
|
+
payeePlaceholder: 'Vendor or biller name, e.g. "MERALCO"',
|
|
225
|
+
showPayee: true,
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
export interface TransactionFormProps {
|
|
230
|
+
error: string;
|
|
231
|
+
saving: boolean;
|
|
232
|
+
category: string;
|
|
233
|
+
setCategory: (v: string) => void;
|
|
234
|
+
subcategory: string;
|
|
235
|
+
setSubcategory: (v: string) => void;
|
|
236
|
+
sourceAccount: string;
|
|
237
|
+
setSourceAccount: (v: string) => void;
|
|
238
|
+
destAccount: string;
|
|
239
|
+
setDestAccount: (v: string) => void;
|
|
240
|
+
amount: string;
|
|
241
|
+
setAmount: (v: string) => void;
|
|
242
|
+
description: string;
|
|
243
|
+
setDescription: (v: string) => void;
|
|
244
|
+
notes: string;
|
|
245
|
+
setNotes: (v: string) => void;
|
|
246
|
+
date: string;
|
|
247
|
+
setDate: (v: string) => void;
|
|
248
|
+
isPrivate: boolean;
|
|
249
|
+
setIsPrivate: (v: boolean) => void;
|
|
250
|
+
sharedWith: string[];
|
|
251
|
+
setSharedWith: (v: string[]) => void;
|
|
252
|
+
sharedRoleCodes: string[];
|
|
253
|
+
setSharedRoleCodes: (v: string[]) => void;
|
|
254
|
+
backdateReason: string;
|
|
255
|
+
setBackdateReason: (v: string) => void;
|
|
256
|
+
payee: string;
|
|
257
|
+
setPayee: (v: string) => void;
|
|
258
|
+
payeeId: number | null;
|
|
259
|
+
setPayeeId: (v: number | null) => void;
|
|
260
|
+
refNumber: string;
|
|
261
|
+
setRefNumber: (v: string) => void;
|
|
262
|
+
taxType: string;
|
|
263
|
+
setTaxType: (v: string) => void;
|
|
264
|
+
hasEwt: boolean;
|
|
265
|
+
setHasEwt: (v: boolean) => void;
|
|
266
|
+
ewtRate: string;
|
|
267
|
+
setEwtRate: (v: string) => void;
|
|
268
|
+
payableKind: string;
|
|
269
|
+
setPayableKind: (v: string) => void;
|
|
270
|
+
dueDate: string;
|
|
271
|
+
setDueDate: (v: string) => void;
|
|
272
|
+
chequeNumber: string;
|
|
273
|
+
setChequeNumber: (v: string) => void;
|
|
274
|
+
pdcStatus: string;
|
|
275
|
+
setPdcStatus: (v: string) => void;
|
|
276
|
+
transferFeeEnabled: boolean;
|
|
277
|
+
setTransferFeeEnabled: (v: boolean) => void;
|
|
278
|
+
transferFeeAmount: string;
|
|
279
|
+
setTransferFeeAmount: (v: string) => void;
|
|
280
|
+
allowTransferFee: boolean;
|
|
281
|
+
pendingFiles: PendingFile[];
|
|
282
|
+
setPendingFiles: (v: PendingFile[]) => void;
|
|
283
|
+
existingAttachments?: TransactionAttachment[];
|
|
284
|
+
onDeleteExistingAttachment?: (attachmentId: number) => Promise<void> | void;
|
|
285
|
+
accounts: TransactionAccount[];
|
|
286
|
+
orgMembers: TransactionOrgMember[];
|
|
287
|
+
shareableRoles: TransactionShareableRole[];
|
|
288
|
+
isAdmin: boolean;
|
|
289
|
+
canShare: boolean;
|
|
290
|
+
isBackdated: boolean;
|
|
291
|
+
saleItems: SalesLine[];
|
|
292
|
+
setSaleItems: (v: SalesLine[]) => void;
|
|
293
|
+
saleClient: ClientOption | null;
|
|
294
|
+
setSaleClient: (v: ClientOption | null) => void;
|
|
295
|
+
saleVoucher: VoucherOption | null;
|
|
296
|
+
setSaleVoucher: (v: VoucherOption | null) => void;
|
|
297
|
+
saleManualDiscount: string;
|
|
298
|
+
setSaleManualDiscount: (v: string) => void;
|
|
299
|
+
onSubmit: () => void;
|
|
300
|
+
submitLabel: string;
|
|
301
|
+
onCancel?: () => void;
|
|
302
|
+
/** Hides the Type picker and the advanced-fields toggle/section entirely.
|
|
303
|
+
* For a caller that locks `category` to one value and never needs
|
|
304
|
+
* EWT/sharing (e.g. a "record my own expense" surface). */
|
|
305
|
+
simpleMode?: boolean;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export default function TransactionForm(props: TransactionFormProps) {
|
|
309
|
+
const catConfig = () =>
|
|
310
|
+
CATEGORY_FORM[props.category] || CATEGORY_FORM.expense;
|
|
311
|
+
const [dragging, setDragging] = createSignal(false);
|
|
312
|
+
const [cameraOpen, setCameraOpen] = createSignal(false);
|
|
313
|
+
const [viewMode, setViewMode] = createSignal<"default" | "advanced">(
|
|
314
|
+
"default"
|
|
315
|
+
);
|
|
316
|
+
const [selectedPayee, setSelectedPayee] = createSignal<PayeeOption | null>(
|
|
317
|
+
null
|
|
318
|
+
);
|
|
319
|
+
createEffect(() => {
|
|
320
|
+
const name = props.payee;
|
|
321
|
+
const id = props.payeeId;
|
|
322
|
+
const sel = selectedPayee();
|
|
323
|
+
if (id != null && name && !sel) {
|
|
324
|
+
setSelectedPayee({ id, name, kind: "vendor" });
|
|
325
|
+
} else if (sel && sel.name !== name) {
|
|
326
|
+
setSelectedPayee(null);
|
|
327
|
+
} else if (!name && sel) {
|
|
328
|
+
setSelectedPayee(null);
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
let dragCounter = 0;
|
|
332
|
+
let formFileInput: HTMLInputElement | undefined;
|
|
333
|
+
|
|
334
|
+
const subcategoryAppliesTo = (): "income" | "expense" | null => {
|
|
335
|
+
if (props.category === "sale") return "income";
|
|
336
|
+
if (props.category === "expense" || props.category === "payable")
|
|
337
|
+
return "expense";
|
|
338
|
+
return null;
|
|
339
|
+
};
|
|
340
|
+
const [subcategoryOptions] = createResource(
|
|
341
|
+
subcategoryAppliesTo,
|
|
342
|
+
async (appliesTo) => {
|
|
343
|
+
if (!appliesTo) return [] as { id: number; name: string }[];
|
|
344
|
+
const res = await fetch(
|
|
345
|
+
`/api/transactions/subcategories?applies_to=${appliesTo}`,
|
|
346
|
+
{
|
|
347
|
+
credentials: "include",
|
|
348
|
+
}
|
|
349
|
+
);
|
|
350
|
+
if (!res.ok) return [] as { id: number; name: string }[];
|
|
351
|
+
const data = (await res.json()) as {
|
|
352
|
+
subcategories: { id: number; name: string }[];
|
|
353
|
+
};
|
|
354
|
+
return data.subcategories;
|
|
355
|
+
}
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
// True once the async resource has resolved at least once. Gates the
|
|
359
|
+
// SearchableSelect mount so the loading-state placeholder shows while the
|
|
360
|
+
// per-tenant options are still in flight.
|
|
361
|
+
const subcategoryOptionsReady = () => subcategoryOptions() !== undefined;
|
|
362
|
+
const categoryOptions = () =>
|
|
363
|
+
props.category === "payable"
|
|
364
|
+
? ["sale", "expense", "business", "payable"]
|
|
365
|
+
: ["sale", "expense", "business"];
|
|
366
|
+
|
|
367
|
+
createEffect(() => {
|
|
368
|
+
if (props.category !== "business" && props.transferFeeEnabled) {
|
|
369
|
+
props.setTransferFeeEnabled(false);
|
|
370
|
+
props.setTransferFeeAmount("");
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
function addFiles(files: File[]) {
|
|
375
|
+
const existing = new Set(
|
|
376
|
+
props.pendingFiles.map(
|
|
377
|
+
(pf) => `${pf.file.name}::${pf.file.size}::${pf.file.lastModified}`
|
|
378
|
+
)
|
|
379
|
+
);
|
|
380
|
+
const deduped = files.filter(
|
|
381
|
+
(f) => !existing.has(`${f.name}::${f.size}::${f.lastModified}`)
|
|
382
|
+
);
|
|
383
|
+
if (deduped.length === 0) return;
|
|
384
|
+
const newPending = deduped.map(createPendingFile);
|
|
385
|
+
props.setPendingFiles([...props.pendingFiles, ...newPending]);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function handleDragEnter(e: DragEvent) {
|
|
389
|
+
e.preventDefault();
|
|
390
|
+
e.stopPropagation();
|
|
391
|
+
dragCounter++;
|
|
392
|
+
if (e.dataTransfer?.types.includes("Files")) setDragging(true);
|
|
393
|
+
}
|
|
394
|
+
function handleDragLeave(e: DragEvent) {
|
|
395
|
+
e.preventDefault();
|
|
396
|
+
e.stopPropagation();
|
|
397
|
+
dragCounter--;
|
|
398
|
+
if (dragCounter === 0) setDragging(false);
|
|
399
|
+
}
|
|
400
|
+
function handleDragOver(e: DragEvent) {
|
|
401
|
+
e.preventDefault();
|
|
402
|
+
e.stopPropagation();
|
|
403
|
+
}
|
|
404
|
+
function handleDrop(e: DragEvent) {
|
|
405
|
+
e.preventDefault();
|
|
406
|
+
e.stopPropagation();
|
|
407
|
+
dragCounter = 0;
|
|
408
|
+
setDragging(false);
|
|
409
|
+
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
|
|
410
|
+
addFiles(Array.from(e.dataTransfer.files));
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
function handlePaste(e: ClipboardEvent) {
|
|
414
|
+
const items = e.clipboardData?.items;
|
|
415
|
+
if (!items) return;
|
|
416
|
+
const files: File[] = [];
|
|
417
|
+
for (let i = 0; i < items.length; i++) {
|
|
418
|
+
const item = items[i];
|
|
419
|
+
if (item.kind === "file") {
|
|
420
|
+
const file = item.getAsFile();
|
|
421
|
+
if (file) files.push(file);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (files.length > 0) {
|
|
425
|
+
e.preventDefault();
|
|
426
|
+
e.stopPropagation();
|
|
427
|
+
addFiles(files);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return (
|
|
432
|
+
<div
|
|
433
|
+
class="relative flex flex-col flex-1 min-h-0"
|
|
434
|
+
onDragEnter={handleDragEnter}
|
|
435
|
+
onDragLeave={handleDragLeave}
|
|
436
|
+
onDragOver={handleDragOver}
|
|
437
|
+
onDrop={handleDrop}
|
|
438
|
+
onPaste={handlePaste}
|
|
439
|
+
>
|
|
440
|
+
<Show when={dragging()}>
|
|
441
|
+
<div class="absolute inset-0 z-30 border-2 border-dashed border-amber-400/60 bg-amber-500/10 backdrop-blur-sm flex flex-col items-center justify-center pointer-events-none">
|
|
442
|
+
<Upload size={32} class="text-amber-400 mb-2" />
|
|
443
|
+
<span class="text-sm text-amber-400 font-medium">
|
|
444
|
+
Drop files to attach
|
|
445
|
+
</span>
|
|
446
|
+
<span class="text-[10px] text-amber-400/60 mt-1">Images or PDFs</span>
|
|
447
|
+
</div>
|
|
448
|
+
</Show>
|
|
449
|
+
|
|
450
|
+
<Show when={cameraOpen()}>
|
|
451
|
+
<CameraCapture
|
|
452
|
+
onCapture={(file) => {
|
|
453
|
+
addFiles([file]);
|
|
454
|
+
setCameraOpen(false);
|
|
455
|
+
}}
|
|
456
|
+
onClose={() => setCameraOpen(false)}
|
|
457
|
+
/>
|
|
458
|
+
</Show>
|
|
459
|
+
|
|
460
|
+
<form
|
|
461
|
+
onSubmit={(e) => {
|
|
462
|
+
e.preventDefault();
|
|
463
|
+
props.onSubmit();
|
|
464
|
+
}}
|
|
465
|
+
class="flex flex-col flex-1 min-h-0"
|
|
466
|
+
>
|
|
467
|
+
<div class="flex-1 overflow-x-hidden overflow-y-auto px-5 sm:px-6 py-5 space-y-4">
|
|
468
|
+
<Show when={props.error}>
|
|
469
|
+
<div
|
|
470
|
+
class="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-400"
|
|
471
|
+
data-testid="transactions-form-error"
|
|
472
|
+
>
|
|
473
|
+
{props.error}
|
|
474
|
+
</div>
|
|
475
|
+
</Show>
|
|
476
|
+
|
|
477
|
+
<Show when={!props.simpleMode}>
|
|
478
|
+
<div>
|
|
479
|
+
<div class="text-[10px] uppercase tracking-widest text-zinc-500 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-zinc-800 bg-transparent text-zinc-500 hover:border-zinc-700 hover:text-zinc-200":
|
|
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-zinc-500 mt-2">{catConfig().hint}</p>
|
|
524
|
+
</div>
|
|
525
|
+
</Show>
|
|
526
|
+
|
|
527
|
+
<Show when={props.category === "sale"}>
|
|
528
|
+
<SalesBodyEditor
|
|
529
|
+
items={props.saleItems}
|
|
530
|
+
setItems={props.setSaleItems}
|
|
531
|
+
client={props.saleClient}
|
|
532
|
+
setClient={props.setSaleClient}
|
|
533
|
+
voucher={props.saleVoucher}
|
|
534
|
+
setVoucher={props.setSaleVoucher}
|
|
535
|
+
manualDiscount={props.saleManualDiscount}
|
|
536
|
+
setManualDiscount={props.setSaleManualDiscount}
|
|
537
|
+
/>
|
|
538
|
+
</Show>
|
|
539
|
+
|
|
540
|
+
<Show
|
|
541
|
+
when={!(props.category === "sale" && props.saleItems.length > 0)}
|
|
542
|
+
>
|
|
543
|
+
<FormField label="Amount *">
|
|
544
|
+
<div class="flex items-stretch gap-2 px-4 py-3 border bg-zinc-900/60 border-zinc-800/60 ks-hud-clip-button focus-within:border-amber-500/50 transition-colors">
|
|
545
|
+
<span class="self-center text-3xl font-bold text-zinc-500 tabular-nums">
|
|
546
|
+
₱
|
|
547
|
+
</span>
|
|
548
|
+
<input
|
|
549
|
+
type="number"
|
|
550
|
+
step="0.01"
|
|
551
|
+
min="0.01"
|
|
552
|
+
data-testid="transactions-form-amount"
|
|
553
|
+
value={props.amount}
|
|
554
|
+
onInput={(e) => props.setAmount(e.currentTarget.value)}
|
|
555
|
+
class="min-w-0 flex-1 bg-transparent text-2xl sm:text-3xl font-bold tabular-nums text-zinc-100 placeholder-zinc-700 focus:outline-none"
|
|
556
|
+
placeholder="0.00"
|
|
557
|
+
required
|
|
558
|
+
/>
|
|
559
|
+
<Show
|
|
560
|
+
when={props.category === "business" && props.allowTransferFee}
|
|
561
|
+
>
|
|
562
|
+
<TransferFeeChip
|
|
563
|
+
enabled={props.transferFeeEnabled}
|
|
564
|
+
onToggle={() => {
|
|
565
|
+
const next = !props.transferFeeEnabled;
|
|
566
|
+
props.setTransferFeeEnabled(next);
|
|
567
|
+
if (!next) props.setTransferFeeAmount("");
|
|
568
|
+
}}
|
|
569
|
+
/>
|
|
570
|
+
</Show>
|
|
571
|
+
</div>
|
|
572
|
+
</FormField>
|
|
573
|
+
<Show
|
|
574
|
+
when={
|
|
575
|
+
props.category === "business" &&
|
|
576
|
+
props.allowTransferFee &&
|
|
577
|
+
props.transferFeeEnabled
|
|
578
|
+
}
|
|
579
|
+
>
|
|
580
|
+
<div
|
|
581
|
+
class="animate-[fin-slide-fade-down_0.28s_ease-out]"
|
|
582
|
+
data-testid="transactions-form-transfer-fee-field"
|
|
583
|
+
>
|
|
584
|
+
<FormField label="Transfer fee *">
|
|
585
|
+
<div class="flex items-center gap-2 px-3 py-2 border bg-zinc-950/50 border-blue-500/30 ks-hud-clip-button focus-within:border-blue-500/60 transition-colors">
|
|
586
|
+
<span class="text-lg font-bold text-zinc-500 tabular-nums">
|
|
587
|
+
₱
|
|
588
|
+
</span>
|
|
589
|
+
<input
|
|
590
|
+
type="number"
|
|
591
|
+
step="0.01"
|
|
592
|
+
min="0.01"
|
|
593
|
+
data-testid="transactions-form-transfer-fee-amount"
|
|
594
|
+
value={props.transferFeeAmount}
|
|
595
|
+
onInput={(e) =>
|
|
596
|
+
props.setTransferFeeAmount(e.currentTarget.value)
|
|
597
|
+
}
|
|
598
|
+
class="min-w-0 flex-1 bg-transparent text-lg font-semibold tabular-nums text-zinc-100 placeholder-zinc-700 focus:outline-none"
|
|
599
|
+
placeholder="0.00"
|
|
600
|
+
/>
|
|
601
|
+
</div>
|
|
602
|
+
<p class="mt-1 text-[10px] text-zinc-600">
|
|
603
|
+
Saved as a separate expense from the source account.
|
|
604
|
+
</p>
|
|
605
|
+
</FormField>
|
|
606
|
+
</div>
|
|
607
|
+
</Show>
|
|
608
|
+
</Show>
|
|
609
|
+
|
|
610
|
+
<FormField label="Date *">
|
|
611
|
+
<DatePicker
|
|
612
|
+
value={props.date}
|
|
613
|
+
onChange={(d: string | null) => d && props.setDate(d)}
|
|
614
|
+
disabled={!props.isAdmin}
|
|
615
|
+
/>
|
|
616
|
+
<Show when={!props.isAdmin}>
|
|
617
|
+
<p class="text-[10px] text-zinc-600 mt-0.5">
|
|
618
|
+
Only admins can change the date
|
|
619
|
+
</p>
|
|
620
|
+
</Show>
|
|
621
|
+
</FormField>
|
|
622
|
+
|
|
623
|
+
<Show when={props.isBackdated}>
|
|
624
|
+
<div class="rounded-lg border border-amber-500/20 bg-amber-500/5 px-3 py-2">
|
|
625
|
+
<FormField label="Backdate Reason *">
|
|
626
|
+
<input
|
|
627
|
+
type="text"
|
|
628
|
+
data-testid="transactions-form-backdate-reason"
|
|
629
|
+
value={props.backdateReason}
|
|
630
|
+
onInput={(e) =>
|
|
631
|
+
props.setBackdateReason(e.currentTarget.value)
|
|
632
|
+
}
|
|
633
|
+
class="w-full rounded-lg border border-amber-500/30 bg-zinc-800/50 px-3 py-2 text-sm text-zinc-200 focus:border-amber-500/50 focus:outline-none"
|
|
634
|
+
placeholder="Why are you backdating this transaction?"
|
|
635
|
+
required
|
|
636
|
+
/>
|
|
637
|
+
</FormField>
|
|
638
|
+
</div>
|
|
639
|
+
</Show>
|
|
640
|
+
|
|
641
|
+
<FormField label="Description *">
|
|
642
|
+
<input
|
|
643
|
+
type="text"
|
|
644
|
+
data-testid="transactions-form-description"
|
|
645
|
+
value={props.description}
|
|
646
|
+
onInput={(e) => props.setDescription(e.currentTarget.value)}
|
|
647
|
+
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"
|
|
648
|
+
placeholder={catConfig().descPlaceholder}
|
|
649
|
+
required
|
|
650
|
+
/>
|
|
651
|
+
</FormField>
|
|
652
|
+
|
|
653
|
+
<Show when={catConfig().showPayee}>
|
|
654
|
+
<div class="grid grid-cols-2 gap-4">
|
|
655
|
+
<FormField label={catConfig().payeeLabel!}>
|
|
656
|
+
<ComboBox<PayeeOption>
|
|
657
|
+
testIdPrefix="form-payee-picker"
|
|
658
|
+
selected={selectedPayee()}
|
|
659
|
+
selectedName={props.payee}
|
|
660
|
+
search={(q) =>
|
|
661
|
+
searchPayees(
|
|
662
|
+
q,
|
|
663
|
+
props.category === "sale" ? "customer" : "vendor"
|
|
664
|
+
)
|
|
665
|
+
}
|
|
666
|
+
onCreate={(name) =>
|
|
667
|
+
createPayee(
|
|
668
|
+
name,
|
|
669
|
+
props.category === "sale" ? "customer" : "vendor"
|
|
670
|
+
)
|
|
671
|
+
}
|
|
672
|
+
idOf={(p) => p.id}
|
|
673
|
+
labelOf={(p) => p.name}
|
|
674
|
+
secondaryOf={payeeSecondary}
|
|
675
|
+
icon={Store}
|
|
676
|
+
noun="payee"
|
|
677
|
+
placeholder={catConfig().payeePlaceholder!}
|
|
678
|
+
onChange={(p) => {
|
|
679
|
+
setSelectedPayee(p);
|
|
680
|
+
props.setPayee(p ? p.name : "");
|
|
681
|
+
props.setPayeeId(p ? p.id : null);
|
|
682
|
+
}}
|
|
683
|
+
/>
|
|
684
|
+
</FormField>
|
|
685
|
+
<FormField label="Receipt / Ref #">
|
|
686
|
+
<input
|
|
687
|
+
type="text"
|
|
688
|
+
value={props.refNumber}
|
|
689
|
+
onInput={(e) => props.setRefNumber(e.currentTarget.value)}
|
|
690
|
+
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"
|
|
691
|
+
placeholder="OR#, SI#, or ref number"
|
|
692
|
+
/>
|
|
693
|
+
</FormField>
|
|
694
|
+
</div>
|
|
695
|
+
</Show>
|
|
696
|
+
<Show when={!catConfig().showPayee}>
|
|
697
|
+
<FormField label="Reference #">
|
|
698
|
+
<input
|
|
699
|
+
type="text"
|
|
700
|
+
value={props.refNumber}
|
|
701
|
+
onInput={(e) => props.setRefNumber(e.currentTarget.value)}
|
|
702
|
+
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"
|
|
703
|
+
placeholder="Reference number (optional)"
|
|
704
|
+
/>
|
|
705
|
+
</FormField>
|
|
706
|
+
</Show>
|
|
707
|
+
|
|
708
|
+
<Show when={subcategoryAppliesTo() !== null}>
|
|
709
|
+
<FormField label="Category">
|
|
710
|
+
<Show
|
|
711
|
+
when={subcategoryOptionsReady()}
|
|
712
|
+
fallback={
|
|
713
|
+
<select
|
|
714
|
+
disabled
|
|
715
|
+
data-testid="subcategory-select-loading"
|
|
716
|
+
class="w-full bg-zinc-900/60 border border-zinc-800/60 px-3 py-3 text-sm text-zinc-500 ks-hud-clip-button focus:outline-none"
|
|
717
|
+
>
|
|
718
|
+
<option>Loading…</option>
|
|
719
|
+
</select>
|
|
720
|
+
}
|
|
721
|
+
>
|
|
722
|
+
<SearchableSelect
|
|
723
|
+
triggerTestId="subcategory-select"
|
|
724
|
+
wrapperClass="relative w-full"
|
|
725
|
+
value={props.subcategory}
|
|
726
|
+
options={(() => {
|
|
727
|
+
const list = (subcategoryOptions() || []).map((opt) => ({
|
|
728
|
+
value: opt.name,
|
|
729
|
+
label: opt.name,
|
|
730
|
+
}));
|
|
731
|
+
list.unshift({ value: "", label: "— Uncategorised —" });
|
|
732
|
+
if (
|
|
733
|
+
props.subcategory &&
|
|
734
|
+
!list.some((o) => o.value === props.subcategory)
|
|
735
|
+
) {
|
|
736
|
+
list.push({
|
|
737
|
+
value: props.subcategory,
|
|
738
|
+
label: props.subcategory,
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
return list;
|
|
742
|
+
})()}
|
|
743
|
+
onChange={(opt) =>
|
|
744
|
+
props.setSubcategory(opt ? String(opt.value) : "")
|
|
745
|
+
}
|
|
746
|
+
placeholder="— Uncategorised —"
|
|
747
|
+
searchPlaceholder="Search categories…"
|
|
748
|
+
triggerClass="w-full bg-zinc-900/60 border border-zinc-800/60 px-3 py-3 text-sm text-zinc-200 ks-hud-clip-button cursor-pointer focus:outline-none focus:border-amber-500/50 flex items-center justify-between gap-2"
|
|
749
|
+
triggerLabelClass="truncate text-left flex-1 min-w-0"
|
|
750
|
+
/>
|
|
751
|
+
</Show>
|
|
752
|
+
<p class="text-[10px] text-zinc-600 mt-0.5">
|
|
753
|
+
Optional. Used for tax-prep classification.
|
|
754
|
+
</p>
|
|
755
|
+
</FormField>
|
|
756
|
+
</Show>
|
|
757
|
+
|
|
758
|
+
<Show when={props.category === "payable"}>
|
|
759
|
+
<div class="rounded-lg border border-amber-500/20 bg-amber-500/5 p-3 space-y-3">
|
|
760
|
+
<div class="flex items-center gap-2 text-[10px] uppercase tracking-widest text-amber-400 font-semibold">
|
|
761
|
+
<CalendarDays size={12} />
|
|
762
|
+
<span>Payable details</span>
|
|
763
|
+
</div>
|
|
764
|
+
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
765
|
+
<FormField label="Kind *">
|
|
766
|
+
<select
|
|
767
|
+
value={props.payableKind}
|
|
768
|
+
onChange={(e) =>
|
|
769
|
+
props.setPayableKind(e.currentTarget.value)
|
|
770
|
+
}
|
|
771
|
+
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 cursor-pointer focus:outline-none focus:border-amber-500/50"
|
|
772
|
+
>
|
|
773
|
+
<For each={PAYABLE_KIND_OPTIONS}>
|
|
774
|
+
{(opt) => <option value={opt.id}>{opt.label}</option>}
|
|
775
|
+
</For>
|
|
776
|
+
</select>
|
|
777
|
+
</FormField>
|
|
778
|
+
<FormField label="Due date">
|
|
779
|
+
<DatePicker
|
|
780
|
+
value={props.dueDate}
|
|
781
|
+
onChange={(d: string | null) => props.setDueDate(d || "")}
|
|
782
|
+
/>
|
|
783
|
+
<p class="text-[10px] text-zinc-600 mt-0.5">
|
|
784
|
+
When payment is owed. Past-due payables show in the Payables
|
|
785
|
+
tab.
|
|
786
|
+
</p>
|
|
787
|
+
</FormField>
|
|
788
|
+
</div>
|
|
789
|
+
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
790
|
+
<FormField label="Cheque number">
|
|
791
|
+
<input
|
|
792
|
+
type="text"
|
|
793
|
+
value={props.chequeNumber}
|
|
794
|
+
onInput={(e) =>
|
|
795
|
+
props.setChequeNumber(e.currentTarget.value)
|
|
796
|
+
}
|
|
797
|
+
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"
|
|
798
|
+
placeholder="e.g. 0004429-007"
|
|
799
|
+
/>
|
|
800
|
+
<p class="text-[10px] text-zinc-600 mt-0.5">
|
|
801
|
+
For post-dated cheques (PDC). Leave blank for direct
|
|
802
|
+
payments.
|
|
803
|
+
</p>
|
|
804
|
+
</FormField>
|
|
805
|
+
<Show when={props.chequeNumber.trim()}>
|
|
806
|
+
<FormField label="PDC status">
|
|
807
|
+
<SegmentedFilter
|
|
808
|
+
options={PDC_OPTIONS.map((opt) => ({
|
|
809
|
+
value: opt.id,
|
|
810
|
+
label: opt.label.replace("PDC ", ""),
|
|
811
|
+
}))}
|
|
812
|
+
value={props.pdcStatus}
|
|
813
|
+
onChange={props.setPdcStatus}
|
|
814
|
+
/>
|
|
815
|
+
</FormField>
|
|
816
|
+
</Show>
|
|
817
|
+
</div>
|
|
818
|
+
</div>
|
|
819
|
+
</Show>
|
|
820
|
+
|
|
821
|
+
<Show
|
|
822
|
+
when={catConfig().showSecondAccount}
|
|
823
|
+
fallback={
|
|
824
|
+
<FormField label={catConfig().accountLabel}>
|
|
825
|
+
<AccountRadioPicker
|
|
826
|
+
accounts={props.accounts}
|
|
827
|
+
ariaLabel={catConfig().accountLabel}
|
|
828
|
+
value={
|
|
829
|
+
props.category === "sale"
|
|
830
|
+
? props.destAccount
|
|
831
|
+
: props.sourceAccount
|
|
832
|
+
}
|
|
833
|
+
onChange={(v) => {
|
|
834
|
+
if (props.category === "sale") {
|
|
835
|
+
props.setDestAccount(v);
|
|
836
|
+
props.setSourceAccount("");
|
|
837
|
+
} else {
|
|
838
|
+
props.setSourceAccount(v);
|
|
839
|
+
props.setDestAccount("");
|
|
840
|
+
}
|
|
841
|
+
}}
|
|
842
|
+
/>
|
|
843
|
+
<Show when={catConfig().accountHint}>
|
|
844
|
+
<p class="text-[10px] text-zinc-600 mt-0.5">
|
|
845
|
+
{catConfig().accountHint}
|
|
846
|
+
</p>
|
|
847
|
+
</Show>
|
|
848
|
+
</FormField>
|
|
849
|
+
}
|
|
850
|
+
>
|
|
851
|
+
<TransferAccountsPicker
|
|
852
|
+
accounts={props.accounts}
|
|
853
|
+
sourceAccount={props.sourceAccount}
|
|
854
|
+
setSourceAccount={props.setSourceAccount}
|
|
855
|
+
destAccount={props.destAccount}
|
|
856
|
+
setDestAccount={props.setDestAccount}
|
|
857
|
+
sourceLabel={catConfig().accountLabel}
|
|
858
|
+
destLabel={catConfig().secondAccountLabel!}
|
|
859
|
+
amount={props.amount}
|
|
860
|
+
feeAmount={props.transferFeeAmount}
|
|
861
|
+
feeEnabled={props.transferFeeEnabled && props.allowTransferFee}
|
|
862
|
+
/>
|
|
863
|
+
</Show>
|
|
864
|
+
|
|
865
|
+
<FormField label="Notes">
|
|
866
|
+
<MentionTextarea
|
|
867
|
+
value={props.notes}
|
|
868
|
+
setValue={props.setNotes}
|
|
869
|
+
class="w-full bg-zinc-900/60 border border-zinc-800/60 px-3 py-2 text-sm text-zinc-200 ks-hud-clip-button focus:outline-none focus:border-amber-500/50 resize-none"
|
|
870
|
+
rows={2}
|
|
871
|
+
placeholder="Optional notes... (type @ to mention a client)"
|
|
872
|
+
ariaLabel="Notes"
|
|
873
|
+
/>
|
|
874
|
+
</FormField>
|
|
875
|
+
|
|
876
|
+
<div>
|
|
877
|
+
<div class="flex items-center gap-1 mb-2 text-xs text-zinc-500">
|
|
878
|
+
<Paperclip size={12} /> Attachments
|
|
879
|
+
<Show
|
|
880
|
+
when={
|
|
881
|
+
(props.existingAttachments?.length ?? 0) +
|
|
882
|
+
props.pendingFiles.length >
|
|
883
|
+
0
|
|
884
|
+
}
|
|
885
|
+
>
|
|
886
|
+
<span class="text-zinc-600">
|
|
887
|
+
(
|
|
888
|
+
{(props.existingAttachments?.length ?? 0) +
|
|
889
|
+
props.pendingFiles.length}
|
|
890
|
+
)
|
|
891
|
+
</span>
|
|
892
|
+
</Show>
|
|
893
|
+
</div>
|
|
894
|
+
|
|
895
|
+
<div class="flex gap-2 overflow-x-auto pt-3 pr-3 pb-2 items-start">
|
|
896
|
+
<For each={props.existingAttachments ?? []}>
|
|
897
|
+
{(att) => (
|
|
898
|
+
<ExistingAttachmentTile
|
|
899
|
+
attachment={att}
|
|
900
|
+
testId="transaction-form-existing-attachment"
|
|
901
|
+
onDelete={props.onDeleteExistingAttachment}
|
|
902
|
+
/>
|
|
903
|
+
)}
|
|
904
|
+
</For>
|
|
905
|
+
<For each={props.pendingFiles}>
|
|
906
|
+
{(pf) => (
|
|
907
|
+
<div class="relative group shrink-0">
|
|
908
|
+
<Show
|
|
909
|
+
when={pf.previewUrl}
|
|
910
|
+
fallback={
|
|
911
|
+
<div class="flex w-24 h-24 flex-col items-center justify-center gap-1 rounded-lg border border-zinc-700 bg-zinc-800/50 px-2 text-xs text-zinc-300">
|
|
912
|
+
<FileIcon size={20} />
|
|
913
|
+
<span class="truncate max-w-full text-[10px]">
|
|
914
|
+
{pf.file.name}
|
|
915
|
+
</span>
|
|
916
|
+
</div>
|
|
917
|
+
}
|
|
918
|
+
>
|
|
919
|
+
<div class="block rounded-lg border border-zinc-700 overflow-hidden">
|
|
920
|
+
<img
|
|
921
|
+
src={pf.previewUrl!}
|
|
922
|
+
alt={pf.file.name}
|
|
923
|
+
class="w-24 h-24 object-cover"
|
|
924
|
+
/>
|
|
925
|
+
</div>
|
|
926
|
+
</Show>
|
|
927
|
+
<button
|
|
928
|
+
type="button"
|
|
929
|
+
onClick={() => {
|
|
930
|
+
revokePendingFile(pf);
|
|
931
|
+
props.setPendingFiles(
|
|
932
|
+
props.pendingFiles.filter((f) => f.id !== pf.id)
|
|
933
|
+
);
|
|
934
|
+
}}
|
|
935
|
+
class="absolute -top-2 -right-2 flex w-7 h-7 items-center justify-center rounded-full bg-red-600/90 border border-red-400/60 text-white cursor-pointer hover:bg-red-500 active:bg-red-700 shadow-lg"
|
|
936
|
+
aria-label={`Remove ${pf.file.name}`}
|
|
937
|
+
>
|
|
938
|
+
<X size={12} />
|
|
939
|
+
</button>
|
|
940
|
+
</div>
|
|
941
|
+
)}
|
|
942
|
+
</For>
|
|
943
|
+
<AddAttachmentTile
|
|
944
|
+
uploading={false}
|
|
945
|
+
onPickFile={() => formFileInput?.click()}
|
|
946
|
+
onPickCamera={() => setCameraOpen(true)}
|
|
947
|
+
/>
|
|
948
|
+
</div>
|
|
949
|
+
|
|
950
|
+
<input
|
|
951
|
+
ref={formFileInput}
|
|
952
|
+
type="file"
|
|
953
|
+
accept="image/*,application/pdf"
|
|
954
|
+
multiple
|
|
955
|
+
class="hidden"
|
|
956
|
+
onChange={(e) => {
|
|
957
|
+
if (e.target.files && e.target.files.length > 0) {
|
|
958
|
+
const newFiles = Array.from(e.target.files).map(
|
|
959
|
+
createPendingFile
|
|
960
|
+
);
|
|
961
|
+
props.setPendingFiles([...props.pendingFiles, ...newFiles]);
|
|
962
|
+
e.target.value = "";
|
|
963
|
+
}
|
|
964
|
+
}}
|
|
965
|
+
/>
|
|
966
|
+
|
|
967
|
+
<Show when={props.pendingFiles.length === 0}>
|
|
968
|
+
<p class="text-[10px] text-zinc-600 mt-1">
|
|
969
|
+
Drop files here or paste from clipboard.
|
|
970
|
+
</p>
|
|
971
|
+
</Show>
|
|
972
|
+
</div>
|
|
973
|
+
|
|
974
|
+
<Show when={!props.simpleMode}>
|
|
975
|
+
<div class="flex justify-center pt-2">
|
|
976
|
+
<button
|
|
977
|
+
type="button"
|
|
978
|
+
onClick={() =>
|
|
979
|
+
setViewMode(viewMode() === "default" ? "advanced" : "default")
|
|
980
|
+
}
|
|
981
|
+
class="text-xs text-zinc-500 hover:text-amber-400 px-3 py-1.5 transition-colors cursor-pointer"
|
|
982
|
+
>
|
|
983
|
+
{viewMode() === "default"
|
|
984
|
+
? "Show advanced fields"
|
|
985
|
+
: "Hide advanced fields"}
|
|
986
|
+
</button>
|
|
987
|
+
</div>
|
|
988
|
+
|
|
989
|
+
<Show when={viewMode() === "advanced"}>
|
|
990
|
+
<FormAdvancedSection
|
|
991
|
+
amount={props.amount}
|
|
992
|
+
category={props.category}
|
|
993
|
+
taxType={props.taxType}
|
|
994
|
+
setTaxType={props.setTaxType}
|
|
995
|
+
hasEwt={props.hasEwt}
|
|
996
|
+
setHasEwt={props.setHasEwt}
|
|
997
|
+
ewtRate={props.ewtRate}
|
|
998
|
+
setEwtRate={props.setEwtRate}
|
|
999
|
+
isPrivate={props.isPrivate}
|
|
1000
|
+
setIsPrivate={props.setIsPrivate}
|
|
1001
|
+
sharedWith={props.sharedWith}
|
|
1002
|
+
setSharedWith={props.setSharedWith}
|
|
1003
|
+
sharedRoleCodes={props.sharedRoleCodes}
|
|
1004
|
+
setSharedRoleCodes={props.setSharedRoleCodes}
|
|
1005
|
+
orgMembers={props.orgMembers}
|
|
1006
|
+
shareableRoles={props.shareableRoles}
|
|
1007
|
+
canShare={props.canShare}
|
|
1008
|
+
/>
|
|
1009
|
+
</Show>
|
|
1010
|
+
</Show>
|
|
1011
|
+
</div>
|
|
1012
|
+
|
|
1013
|
+
<div class="px-5 sm:px-6 py-4 border-t border-zinc-800/60 bg-zinc-950 flex flex-col-reverse sm:flex-row sm:items-center sm:justify-between gap-3 shrink-0">
|
|
1014
|
+
<div class="text-xs text-zinc-500">
|
|
1015
|
+
<span class="text-zinc-600">Will record as </span>
|
|
1016
|
+
<span
|
|
1017
|
+
class="font-bold"
|
|
1018
|
+
classList={{
|
|
1019
|
+
"text-emerald-400": props.category === "sale",
|
|
1020
|
+
"text-red-400": props.category === "expense",
|
|
1021
|
+
"text-amber-400": props.category === "payable",
|
|
1022
|
+
"text-blue-400": props.category === "business",
|
|
1023
|
+
}}
|
|
1024
|
+
>
|
|
1025
|
+
{(CATEGORY_FORM[props.category] || CATEGORY_FORM.expense).label}
|
|
1026
|
+
</span>
|
|
1027
|
+
</div>
|
|
1028
|
+
<div class="flex justify-end gap-3">
|
|
1029
|
+
<Show when={props.onCancel}>
|
|
1030
|
+
<Button
|
|
1031
|
+
intent="secondary"
|
|
1032
|
+
variant="ghost"
|
|
1033
|
+
onClick={props.onCancel}
|
|
1034
|
+
disabled={props.saving}
|
|
1035
|
+
>
|
|
1036
|
+
Cancel
|
|
1037
|
+
</Button>
|
|
1038
|
+
</Show>
|
|
1039
|
+
<Button
|
|
1040
|
+
intent="primary"
|
|
1041
|
+
variant="clip1"
|
|
1042
|
+
type="submit"
|
|
1043
|
+
disabled={props.saving}
|
|
1044
|
+
data-testid="transactions-form-submit"
|
|
1045
|
+
>
|
|
1046
|
+
{props.saving ? "Saving..." : props.submitLabel}
|
|
1047
|
+
</Button>
|
|
1048
|
+
</div>
|
|
1049
|
+
</div>
|
|
1050
|
+
</form>
|
|
1051
|
+
</div>
|
|
1052
|
+
);
|
|
1053
|
+
}
|