Add opening balance, manual transactions, and cross-currency transfers
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,8 +8,45 @@ import {
|
|||||||
} from '$lib/server/db/schema.js';
|
} from '$lib/server/db/schema.js';
|
||||||
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
|
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
|
||||||
import { logCompanyEvent } from '$lib/server/audit.js';
|
import { logCompanyEvent } from '$lib/server/audit.js';
|
||||||
|
import {
|
||||||
|
postTransaction,
|
||||||
|
postTransfer,
|
||||||
|
type CompanyAccountTxnType
|
||||||
|
} from '$lib/server/accounts/ledger.js';
|
||||||
import { and, asc, eq, isNull, sql } from 'drizzle-orm';
|
import { and, asc, eq, isNull, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
const MANUAL_TXN_TYPES = ['deposit', 'adjustment'] as const;
|
||||||
|
type ManualTxnType = (typeof MANUAL_TXN_TYPES)[number];
|
||||||
|
|
||||||
|
function parseManualTxnType(v: FormDataEntryValue | null): ManualTxnType | null {
|
||||||
|
const s = v?.toString();
|
||||||
|
if (!s) return null;
|
||||||
|
return (MANUAL_TXN_TYPES as readonly string[]).includes(s) ? (s as ManualTxnType) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDate(v: FormDataEntryValue | null): Date | null {
|
||||||
|
const s = v?.toString();
|
||||||
|
if (!s) return null;
|
||||||
|
const d = new Date(s);
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePositiveAmount(v: FormDataEntryValue | null): string | null {
|
||||||
|
const s = v?.toString();
|
||||||
|
if (!s) return null;
|
||||||
|
const n = Number(s);
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return null;
|
||||||
|
return n.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSignedAmount(v: FormDataEntryValue | null): string | null {
|
||||||
|
const s = v?.toString();
|
||||||
|
if (!s) return null;
|
||||||
|
const n = Number(s);
|
||||||
|
if (!Number.isFinite(n) || n === 0) return null;
|
||||||
|
return n.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
const ACCOUNT_TYPES = [
|
const ACCOUNT_TYPES = [
|
||||||
'bank',
|
'bank',
|
||||||
'credit_card',
|
'credit_card',
|
||||||
@@ -253,41 +290,61 @@ export const actions: Actions = {
|
|||||||
const f = parsed.fields;
|
const f = parsed.fields;
|
||||||
|
|
||||||
const sortOrder = await nextAccountSortOrder(params.companyId);
|
const sortOrder = await nextAccountSortOrder(params.companyId);
|
||||||
|
const openingBalance = parseSignedAmount(fd.get('openingBalance'));
|
||||||
|
const openingBalanceDate =
|
||||||
|
parseDate(fd.get('openingBalanceDate')) ?? new Date();
|
||||||
|
|
||||||
const [inserted] = await db
|
const inserted = await db.transaction(async (tx) => {
|
||||||
.insert(companyAccounts)
|
const [row] = await tx
|
||||||
.values({
|
.insert(companyAccounts)
|
||||||
companyId: params.companyId,
|
.values({
|
||||||
sortOrder,
|
companyId: params.companyId,
|
||||||
createdBy: user.id,
|
sortOrder,
|
||||||
accountType: f.accountType,
|
createdBy: user.id,
|
||||||
name: f.name,
|
accountType: f.accountType,
|
||||||
currency: f.currency,
|
name: f.name,
|
||||||
notes: f.notes,
|
currency: f.currency,
|
||||||
bankName: f.bankName,
|
notes: f.notes,
|
||||||
accountNumber: f.accountNumber,
|
bankName: f.bankName,
|
||||||
branch: f.branch,
|
accountNumber: f.accountNumber,
|
||||||
swiftBic: f.swiftBic,
|
branch: f.branch,
|
||||||
iban: f.iban,
|
swiftBic: f.swiftBic,
|
||||||
accountHolderName: f.accountHolderName,
|
iban: f.iban,
|
||||||
cardBrand: f.cardBrand,
|
accountHolderName: f.accountHolderName,
|
||||||
last4: f.last4,
|
cardBrand: f.cardBrand,
|
||||||
cardholderName: f.cardholderName,
|
last4: f.last4,
|
||||||
expiryMonth: f.expiryMonth,
|
cardholderName: f.cardholderName,
|
||||||
expiryYear: f.expiryYear,
|
expiryMonth: f.expiryMonth,
|
||||||
creditLimit: f.creditLimit,
|
expiryYear: f.expiryYear,
|
||||||
statementCloseDay: f.statementCloseDay,
|
creditLimit: f.creditLimit,
|
||||||
paymentDueDay: f.paymentDueDay,
|
statementCloseDay: f.statementCloseDay,
|
||||||
externalAccountId: f.externalAccountId
|
paymentDueDay: f.paymentDueDay,
|
||||||
})
|
externalAccountId: f.externalAccountId
|
||||||
.returning({ id: companyAccounts.id });
|
})
|
||||||
|
.returning({ id: companyAccounts.id });
|
||||||
|
|
||||||
|
if (openingBalance !== null) {
|
||||||
|
await postTransaction(tx, {
|
||||||
|
accountId: row.id,
|
||||||
|
companyId: params.companyId,
|
||||||
|
type: 'opening_balance',
|
||||||
|
amount: openingBalance,
|
||||||
|
currency: f.currency,
|
||||||
|
occurredAt: openingBalanceDate,
|
||||||
|
description: 'Opening balance',
|
||||||
|
createdBy: user.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
await logCompanyEvent(
|
await logCompanyEvent(
|
||||||
params.companyId,
|
params.companyId,
|
||||||
user.id,
|
user.id,
|
||||||
'account_created',
|
'account_created',
|
||||||
`Account "${parsed.fields.name}" created`,
|
`Account "${parsed.fields.name}" created${openingBalance !== null ? ` with opening balance ${openingBalance} ${f.currency}` : ''}`,
|
||||||
{ accountId: inserted.id, accountType: parsed.fields.accountType }
|
{ accountId: inserted.id, accountType: parsed.fields.accountType, openingBalance }
|
||||||
);
|
);
|
||||||
|
|
||||||
return { success: true, action: 'addAccount' };
|
return { success: true, action: 'addAccount' };
|
||||||
@@ -502,5 +559,137 @@ export const actions: Actions = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return { success: true, action: 'reorderAccounts' };
|
return { success: true, action: 'reorderAccounts' };
|
||||||
|
},
|
||||||
|
|
||||||
|
postTransfer: async ({ request, locals, params }) => {
|
||||||
|
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
|
||||||
|
'admin',
|
||||||
|
'manager',
|
||||||
|
'accountant'
|
||||||
|
]);
|
||||||
|
const fd = await request.formData();
|
||||||
|
const fromAccountId = trimOrNull(fd.get('fromAccountId'));
|
||||||
|
const toAccountId = trimOrNull(fd.get('toAccountId'));
|
||||||
|
const amount = parsePositiveAmount(fd.get('amount'));
|
||||||
|
const occurredAt = parseDate(fd.get('occurredAt'));
|
||||||
|
const description = trimOrNull(fd.get('description'));
|
||||||
|
const reference = trimOrNull(fd.get('reference'));
|
||||||
|
const fxRate = trimOrNull(fd.get('fxRate'));
|
||||||
|
const destinationAmount = trimOrNull(fd.get('destinationAmount'));
|
||||||
|
|
||||||
|
if (!fromAccountId || !toAccountId) {
|
||||||
|
return fail(400, { action: 'postTransfer', error: 'Both from and to accounts are required' });
|
||||||
|
}
|
||||||
|
if (fromAccountId === toAccountId) {
|
||||||
|
return fail(400, { action: 'postTransfer', error: 'From and to accounts must differ' });
|
||||||
|
}
|
||||||
|
if (!amount) {
|
||||||
|
return fail(400, { action: 'postTransfer', error: 'Amount must be a positive number' });
|
||||||
|
}
|
||||||
|
if (!occurredAt) {
|
||||||
|
return fail(400, { action: 'postTransfer', error: 'Valid date is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await postTransfer({
|
||||||
|
fromAccountId,
|
||||||
|
toAccountId,
|
||||||
|
companyId: params.companyId,
|
||||||
|
amount,
|
||||||
|
occurredAt,
|
||||||
|
description,
|
||||||
|
reference,
|
||||||
|
fxRate,
|
||||||
|
destinationAmount,
|
||||||
|
createdBy: user.id
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Transfer failed';
|
||||||
|
return fail(400, { action: 'postTransfer', error: msg });
|
||||||
|
}
|
||||||
|
|
||||||
|
await logCompanyEvent(
|
||||||
|
params.companyId,
|
||||||
|
user.id,
|
||||||
|
'account_transfer_posted',
|
||||||
|
`Transferred ${amount} from account ${fromAccountId} to ${toAccountId}`,
|
||||||
|
{ fromAccountId, toAccountId, amount, fxRate, destinationAmount }
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true, action: 'postTransfer' };
|
||||||
|
},
|
||||||
|
|
||||||
|
addManualTransaction: async ({ request, locals, params }) => {
|
||||||
|
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
|
||||||
|
'admin',
|
||||||
|
'manager',
|
||||||
|
'accountant'
|
||||||
|
]);
|
||||||
|
const fd = await request.formData();
|
||||||
|
const accountId = trimOrNull(fd.get('accountId'));
|
||||||
|
const type = parseManualTxnType(fd.get('type'));
|
||||||
|
const amount = parseSignedAmount(fd.get('amount'));
|
||||||
|
const occurredAt = parseDate(fd.get('occurredAt'));
|
||||||
|
const description = trimOrNull(fd.get('description'));
|
||||||
|
const reference = trimOrNull(fd.get('reference'));
|
||||||
|
|
||||||
|
if (!accountId) {
|
||||||
|
return fail(400, { action: 'addManualTransaction', error: 'Account is required' });
|
||||||
|
}
|
||||||
|
if (!type) {
|
||||||
|
return fail(400, {
|
||||||
|
action: 'addManualTransaction',
|
||||||
|
error: 'Type must be deposit or adjustment'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!amount) {
|
||||||
|
return fail(400, {
|
||||||
|
action: 'addManualTransaction',
|
||||||
|
error: 'Amount must be a non-zero number'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!occurredAt) {
|
||||||
|
return fail(400, { action: 'addManualTransaction', error: 'Valid date is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [acct] = await db
|
||||||
|
.select({
|
||||||
|
id: companyAccounts.id,
|
||||||
|
currency: companyAccounts.currency,
|
||||||
|
name: companyAccounts.name
|
||||||
|
})
|
||||||
|
.from(companyAccounts)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(companyAccounts.id, accountId),
|
||||||
|
eq(companyAccounts.companyId, params.companyId),
|
||||||
|
isNull(companyAccounts.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!acct) error(404, 'Account not found');
|
||||||
|
|
||||||
|
const txnType: CompanyAccountTxnType = type;
|
||||||
|
await postTransaction(db, {
|
||||||
|
accountId,
|
||||||
|
companyId: params.companyId,
|
||||||
|
type: txnType,
|
||||||
|
amount,
|
||||||
|
currency: acct.currency,
|
||||||
|
occurredAt,
|
||||||
|
description,
|
||||||
|
reference,
|
||||||
|
createdBy: user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
await logCompanyEvent(
|
||||||
|
params.companyId,
|
||||||
|
user.id,
|
||||||
|
'account_transaction_added',
|
||||||
|
`${type} of ${amount} ${acct.currency} recorded on "${acct.name}"`,
|
||||||
|
{ accountId, type, amount }
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true, action: 'addManualTransaction' };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -37,16 +37,57 @@
|
|||||||
const ACCOUNT_TYPES = Object.keys(ACCOUNT_TYPE_LABELS) as AccountType[];
|
const ACCOUNT_TYPES = Object.keys(ACCOUNT_TYPE_LABELS) as AccountType[];
|
||||||
|
|
||||||
let showAddForm = $state(false);
|
let showAddForm = $state(false);
|
||||||
|
let showTransferModal = $state(false);
|
||||||
|
let showManualTxnModal = $state(false);
|
||||||
let editingId = $state<string | null>(null);
|
let editingId = $state<string | null>(null);
|
||||||
let confirmDeleteId = $state<string | null>(null);
|
let confirmDeleteId = $state<string | null>(null);
|
||||||
let addType = $state<AccountType>('bank');
|
let addType = $state<AccountType>('bank');
|
||||||
|
let transferFrom = $state<string>('');
|
||||||
|
let transferTo = $state<string>('');
|
||||||
|
|
||||||
|
const activeAccounts = $derived(data.accounts.filter((a) => !a.isArchived));
|
||||||
|
const fromAccount = $derived(activeAccounts.find((a) => a.id === transferFrom));
|
||||||
|
const toAccount = $derived(activeAccounts.find((a) => a.id === transferTo));
|
||||||
|
const isCrossCurrency = $derived(
|
||||||
|
fromAccount && toAccount && fromAccount.currency !== toAccount.currency
|
||||||
|
);
|
||||||
|
|
||||||
function openAdd() {
|
function openAdd() {
|
||||||
showAddForm = !showAddForm;
|
showAddForm = !showAddForm;
|
||||||
|
showTransferModal = false;
|
||||||
|
showManualTxnModal = false;
|
||||||
editingId = null;
|
editingId = null;
|
||||||
confirmDeleteId = null;
|
confirmDeleteId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openTransfer() {
|
||||||
|
showTransferModal = true;
|
||||||
|
showAddForm = false;
|
||||||
|
showManualTxnModal = false;
|
||||||
|
editingId = null;
|
||||||
|
confirmDeleteId = null;
|
||||||
|
if (activeAccounts.length >= 2) {
|
||||||
|
transferFrom = activeAccounts[0].id;
|
||||||
|
transferTo = activeAccounts[1].id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openManualTxn() {
|
||||||
|
showManualTxnModal = true;
|
||||||
|
showAddForm = false;
|
||||||
|
showTransferModal = false;
|
||||||
|
editingId = null;
|
||||||
|
confirmDeleteId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayIso(): string {
|
||||||
|
const d = new Date();
|
||||||
|
const yyyy = d.getFullYear();
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${yyyy}-${mm}-${dd}`;
|
||||||
|
}
|
||||||
|
|
||||||
function formatAmount(amount: string, currency: string): string {
|
function formatAmount(amount: string, currency: string): string {
|
||||||
const n = Number(amount);
|
const n = Number(amount);
|
||||||
const fmt = new Intl.NumberFormat(undefined, {
|
const fmt = new Intl.NumberFormat(undefined, {
|
||||||
@@ -326,13 +367,33 @@
|
|||||||
expenses, invoice payments, transfers, and manual entries.
|
expenses, invoice payments, transfers, and manual entries.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div class="flex flex-wrap gap-2">
|
||||||
type="button"
|
{#if activeAccounts.length >= 1}
|
||||||
onclick={openAdd}
|
<button
|
||||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
type="button"
|
||||||
>
|
onclick={openManualTxn}
|
||||||
{showAddForm ? 'Cancel' : '+ New Account'}
|
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||||
</button>
|
>
|
||||||
|
Record Transaction
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if activeAccounts.length >= 2}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={openTransfer}
|
||||||
|
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Transfer
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={openAdd}
|
||||||
|
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
{showAddForm ? 'Cancel' : '+ New Account'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if form?.error}
|
{#if form?.error}
|
||||||
@@ -377,6 +438,38 @@
|
|||||||
|
|
||||||
{@render accountFields(addType, 'add')}
|
{@render accountFields(addType, 'add')}
|
||||||
|
|
||||||
|
<fieldset class="mt-4 rounded-md border border-gray-200 p-3 dark:border-gray-600">
|
||||||
|
<legend class="px-2 text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
Opening balance (optional)
|
||||||
|
</legend>
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label for="add-openingBalance" class={labelCls}>
|
||||||
|
Opening Balance
|
||||||
|
<span class="ml-1 text-xs text-gray-400">(negative for credit-card debt)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="add-openingBalance"
|
||||||
|
name="openingBalance"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="0.00"
|
||||||
|
class={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="add-openingBalanceDate" class={labelCls}>As of Date</label>
|
||||||
|
<input
|
||||||
|
id="add-openingBalanceDate"
|
||||||
|
name="openingBalanceDate"
|
||||||
|
type="date"
|
||||||
|
value={todayIso()}
|
||||||
|
class={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<div class="mt-4 flex justify-end gap-2">
|
<div class="mt-4 flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -395,6 +488,221 @@
|
|||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if showTransferModal}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/postTransfer"
|
||||||
|
use:enhance={() => async ({ result, update, formElement }) => {
|
||||||
|
await update({ reset: false });
|
||||||
|
if (result.type === 'success') {
|
||||||
|
showTransferModal = false;
|
||||||
|
formElement.reset();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<h2 class="mb-4 font-semibold text-gray-900 dark:text-white">Transfer Between Accounts</h2>
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label for="xfer-from" class={labelCls}>From <span class="text-red-500">*</span></label>
|
||||||
|
<select id="xfer-from" name="fromAccountId" required bind:value={transferFrom} class={inputCls}>
|
||||||
|
{#each activeAccounts as a (a.id)}
|
||||||
|
<option value={a.id}>{a.name} ({a.currency})</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="xfer-to" class={labelCls}>To <span class="text-red-500">*</span></label>
|
||||||
|
<select id="xfer-to" name="toAccountId" required bind:value={transferTo} class={inputCls}>
|
||||||
|
{#each activeAccounts as a (a.id)}
|
||||||
|
<option value={a.id}>{a.name} ({a.currency})</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="xfer-amount" class={labelCls}>
|
||||||
|
Amount <span class="text-red-500">*</span>
|
||||||
|
{#if fromAccount}<span class="ml-1 text-xs text-gray-400">({fromAccount.currency})</span>{/if}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="xfer-amount"
|
||||||
|
name="amount"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0.01"
|
||||||
|
required
|
||||||
|
class={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="xfer-date" class={labelCls}>Date <span class="text-red-500">*</span></label>
|
||||||
|
<input
|
||||||
|
id="xfer-date"
|
||||||
|
name="occurredAt"
|
||||||
|
type="date"
|
||||||
|
value={todayIso()}
|
||||||
|
required
|
||||||
|
class={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if isCrossCurrency}
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<p class="mb-2 text-xs text-amber-600 dark:text-amber-400">
|
||||||
|
Cross-currency transfer: enter either an FX rate OR a destination amount.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="xfer-rate" class={labelCls}>
|
||||||
|
FX Rate
|
||||||
|
{#if fromAccount && toAccount}
|
||||||
|
<span class="ml-1 text-xs text-gray-400"
|
||||||
|
>(1 {fromAccount.currency} = ? {toAccount.currency})</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="xfer-rate"
|
||||||
|
name="fxRate"
|
||||||
|
type="number"
|
||||||
|
step="0.00000001"
|
||||||
|
min="0"
|
||||||
|
placeholder="e.g. 36.5"
|
||||||
|
class={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="xfer-destAmt" class={labelCls}>
|
||||||
|
— or — Destination Amount
|
||||||
|
{#if toAccount}<span class="ml-1 text-xs text-gray-400">({toAccount.currency})</span>{/if}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="xfer-destAmt"
|
||||||
|
name="destinationAmount"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
placeholder="e.g. 36500.00"
|
||||||
|
class={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label for="xfer-description" class={labelCls}>Description</label>
|
||||||
|
<input
|
||||||
|
id="xfer-description"
|
||||||
|
name="description"
|
||||||
|
type="text"
|
||||||
|
placeholder="Optional note"
|
||||||
|
class={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label for="xfer-reference" class={labelCls}>Reference</label>
|
||||||
|
<input id="xfer-reference" name="reference" type="text" class={inputCls} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (showTransferModal = false)}
|
||||||
|
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Post Transfer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showManualTxnModal}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/addManualTransaction"
|
||||||
|
use:enhance={() => async ({ result, update, formElement }) => {
|
||||||
|
await update({ reset: false });
|
||||||
|
if (result.type === 'success') {
|
||||||
|
showManualTxnModal = false;
|
||||||
|
formElement.reset();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<h2 class="mb-4 font-semibold text-gray-900 dark:text-white">Record Transaction</h2>
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label for="mtxn-account" class={labelCls}>Account <span class="text-red-500">*</span></label>
|
||||||
|
<select id="mtxn-account" name="accountId" required class={inputCls}>
|
||||||
|
{#each activeAccounts as a (a.id)}
|
||||||
|
<option value={a.id}>{a.name} ({a.currency})</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="mtxn-type" class={labelCls}>Type <span class="text-red-500">*</span></label>
|
||||||
|
<select id="mtxn-type" name="type" required class={inputCls}>
|
||||||
|
<option value="deposit">Deposit (credit)</option>
|
||||||
|
<option value="adjustment">Adjustment (debit or credit)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="mtxn-amount" class={labelCls}>
|
||||||
|
Amount <span class="text-red-500">*</span>
|
||||||
|
<span class="ml-1 text-xs text-gray-400">(positive = credit, negative = debit)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="mtxn-amount"
|
||||||
|
name="amount"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
required
|
||||||
|
class={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="mtxn-date" class={labelCls}>Date <span class="text-red-500">*</span></label>
|
||||||
|
<input
|
||||||
|
id="mtxn-date"
|
||||||
|
name="occurredAt"
|
||||||
|
type="date"
|
||||||
|
value={todayIso()}
|
||||||
|
required
|
||||||
|
class={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label for="mtxn-description" class={labelCls}>Description</label>
|
||||||
|
<input id="mtxn-description" name="description" type="text" class={inputCls} />
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label for="mtxn-reference" class={labelCls}>Reference</label>
|
||||||
|
<input id="mtxn-reference" name="reference" type="text" class={inputCls} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (showManualTxnModal = false)}
|
||||||
|
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Record
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if data.accounts.length === 0}
|
{#if data.accounts.length === 0}
|
||||||
<div
|
<div
|
||||||
class="rounded-lg border border-dashed border-gray-300 bg-white p-10 text-center dark:border-gray-700 dark:bg-gray-800"
|
class="rounded-lg border border-dashed border-gray-300 bg-white p-10 text-center dark:border-gray-700 dark:bg-gray-800"
|
||||||
|
|||||||
Reference in New Issue
Block a user