Compare commits
2 Commits
115a79a5c0
...
c0542900be
| Author | SHA1 | Date |
|---|---|---|
|
|
c0542900be | |
|
|
6280ab7cfb |
|
|
@ -4,6 +4,7 @@
|
||||||
],
|
],
|
||||||
"copyright": "Copyright © 2024, Andrew Lalis",
|
"copyright": "Copyright © 2024, Andrew Lalis",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"cronexp": ">=0.1.0-beta3 <0.2.0-0",
|
||||||
"d2sqlite3": "~>1.0",
|
"d2sqlite3": "~>1.0",
|
||||||
"handy-http-starter": "~>1.6",
|
"handy-http-starter": "~>1.6",
|
||||||
"jwt4d": "~>0.0.2",
|
"jwt4d": "~>0.0.2",
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
"fileVersion": 1,
|
"fileVersion": 1,
|
||||||
"versions": {
|
"versions": {
|
||||||
"asdf": "0.8.0",
|
"asdf": "0.8.0",
|
||||||
|
"cronexp": "0.1.0-beta3",
|
||||||
"d2sqlite3": "1.0.0",
|
"d2sqlite3": "1.0.0",
|
||||||
"dxml": "0.4.5",
|
"dxml": "0.4.5",
|
||||||
"handy-http-data": "1.3.2",
|
"handy-http-data": "1.3.2",
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ interface ProfileDataSource {
|
||||||
TransactionTagRepository getTransactionTagRepository();
|
TransactionTagRepository getTransactionTagRepository();
|
||||||
TransactionRepository getTransactionRepository();
|
TransactionRepository getTransactionRepository();
|
||||||
TransactionDraftRepository getTransactionDraftRepository();
|
TransactionDraftRepository getTransactionDraftRepository();
|
||||||
|
RecurringTransactionRepository getRecurringTransactionRepository();
|
||||||
|
|
||||||
AnalyticsRepository getAnalyticsRepository();
|
AnalyticsRepository getAnalyticsRepository();
|
||||||
|
|
||||||
|
|
@ -97,6 +98,9 @@ version(unittest) {
|
||||||
TransactionDraftRepository getTransactionDraftRepository() {
|
TransactionDraftRepository getTransactionDraftRepository() {
|
||||||
throw new Exception("Not implemented");
|
throw new Exception("Not implemented");
|
||||||
}
|
}
|
||||||
|
RecurringTransactionRepository getRecurringTransactionRepository() {
|
||||||
|
throw new Exception("Not implemented");
|
||||||
|
}
|
||||||
AnalyticsRepository getAnalyticsRepository() {
|
AnalyticsRepository getAnalyticsRepository() {
|
||||||
throw new Exception("Not implemented");
|
throw new Exception("Not implemented");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -216,6 +216,7 @@ class SqliteProfileDataSource : ProfileDataSource {
|
||||||
TransactionTagRepository transactionTagRepo;
|
TransactionTagRepository transactionTagRepo;
|
||||||
TransactionRepository transactionRepo;
|
TransactionRepository transactionRepo;
|
||||||
TransactionDraftRepository transactionDraftRepo;
|
TransactionDraftRepository transactionDraftRepo;
|
||||||
|
RecurringTransactionRepository recurringTransactionRepo;
|
||||||
AnalyticsRepository analyticsRepo;
|
AnalyticsRepository analyticsRepo;
|
||||||
|
|
||||||
this(string path) {
|
this(string path) {
|
||||||
|
|
@ -305,6 +306,13 @@ class SqliteProfileDataSource : ProfileDataSource {
|
||||||
return transactionDraftRepo;
|
return transactionDraftRepo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RecurringTransactionRepository getRecurringTransactionRepository() {
|
||||||
|
if (recurringTransactionRepo is null) {
|
||||||
|
recurringTransactionRepo = new SqliteRecurringTransactionRepository(db);
|
||||||
|
}
|
||||||
|
return recurringTransactionRepo;
|
||||||
|
}
|
||||||
|
|
||||||
AnalyticsRepository getAnalyticsRepository() {
|
AnalyticsRepository getAnalyticsRepository() {
|
||||||
if (analyticsRepo is null) {
|
if (analyticsRepo is null) {
|
||||||
analyticsRepo = new SqliteAnalyticsRepository(db);
|
analyticsRepo = new SqliteAnalyticsRepository(db);
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ void startScheduledJobs() {
|
||||||
);
|
);
|
||||||
|
|
||||||
JobScheduler jobScheduler = new TaskPoolScheduler();
|
JobScheduler jobScheduler = new TaskPoolScheduler();
|
||||||
|
|
||||||
jobScheduler.addJob(() {
|
jobScheduler.addJob(() {
|
||||||
// Clear old analytics data from profiles.
|
// Clear old analytics data from profiles.
|
||||||
import profile.data;
|
import profile.data;
|
||||||
|
|
@ -38,5 +39,32 @@ void startScheduledJobs() {
|
||||||
);
|
);
|
||||||
|
|
||||||
}, analyticsSchedule);
|
}, analyticsSchedule);
|
||||||
|
|
||||||
|
// Add a scheduled job to regularly check for and create recurring transaction drafts.
|
||||||
|
jobScheduler.addJob(() {
|
||||||
|
import profile.data;
|
||||||
|
import profile.data_impl_sqlite;
|
||||||
|
import profile.model;
|
||||||
|
import transaction.dto;
|
||||||
|
import transaction.data;
|
||||||
|
import util.pagination;
|
||||||
|
import std.stdio;
|
||||||
|
import std.datetime;
|
||||||
|
import cronexp;
|
||||||
|
FileSystemProfileRepository.doForAllUserProfiles((Profile profile, ProfileRepository profileRepo) {
|
||||||
|
writefln!"Recurring transaction check: %s / %s"(profile.username, profile.name);
|
||||||
|
ProfileDataSource ds = profileRepo.getDataSource(profile);
|
||||||
|
RecurringTransactionRepository rtRepo = ds.getRecurringTransactionRepository();
|
||||||
|
Page!(RecurringTransactionResponse) result = rtRepo.findAll(PageRequest.unpaged());
|
||||||
|
foreach (rt; result.items) {
|
||||||
|
writeln(rt.scheduleExpr);
|
||||||
|
DateTime now = cast(DateTime) Clock.currTime();
|
||||||
|
auto cron = CronExpr(rt.scheduleExpr);
|
||||||
|
writefln!"scheduleExpr = %s -> %s"(rt.scheduleExpr, cron.getNext(now));
|
||||||
|
// TODO: Figure out how to actually create the transactions!
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, new FixedIntervalSchedule(minutes(1), Clock.currTime(UTC())));
|
||||||
|
|
||||||
jobScheduler.start();
|
jobScheduler.start();
|
||||||
}
|
}
|
||||||
|
|
@ -216,7 +216,7 @@ private ulong getCategoryId(in ServerHttpRequest request) {
|
||||||
return getPathParamOrThrow!ulong(request, "categoryId");
|
return getPathParamOrThrow!ulong(request, "categoryId");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drafts & Templates
|
// Drafts & Templates & Recurring Transactions
|
||||||
|
|
||||||
immutable DEFAULT_DRAFT_PAGE = PageRequest(1, 10, [Sort("draft.id", SortDir.DESC)]);
|
immutable DEFAULT_DRAFT_PAGE = PageRequest(1, 10, [Sort("draft.id", SortDir.DESC)]);
|
||||||
|
|
||||||
|
|
@ -264,6 +264,46 @@ void handleDeleteDraft(ref ServerHttpRequest request, ref ServerHttpResponse res
|
||||||
deleteDraft(ds, draftId);
|
deleteDraft(ds, draftId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping(PROFILE_PATH ~ "/transaction-drafts/:draftId:ulong/recurring-transactions")
|
||||||
|
void handleGetDraftRecurringTransactions(
|
||||||
|
ref ServerHttpRequest request,
|
||||||
|
ref ServerHttpResponse response
|
||||||
|
) {
|
||||||
|
ProfileDataSource ds = getProfileDataSource(request);
|
||||||
|
ulong draftId = getDraftId(request);
|
||||||
|
RecurringTransactionResponse[] results = getRecurringTransactionsForDraft(ds, draftId);
|
||||||
|
writeJsonBody(response, results);
|
||||||
|
}
|
||||||
|
|
||||||
private ulong getDraftId(in ServerHttpRequest request) {
|
private ulong getDraftId(in ServerHttpRequest request) {
|
||||||
return getPathParamOrThrow!ulong(request, "draftId");
|
return getPathParamOrThrow!ulong(request, "draftId");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
immutable DEFAULT_RECURRING_TRANSACTIONS_PAGE = PageRequest(1, 10, [Sort("id", SortDir.DESC)]);
|
||||||
|
|
||||||
|
@GetMapping(PROFILE_PATH ~ "/recurring-transactions")
|
||||||
|
void handleGetRecurringTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
|
ProfileDataSource ds = getProfileDataSource(request);
|
||||||
|
PageRequest pr = PageRequest.parse(request, DEFAULT_RECURRING_TRANSACTIONS_PAGE);
|
||||||
|
Page!RecurringTransactionResponse page = getRecurringTransactions(ds, pr);
|
||||||
|
writeJsonBody(response, page);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(PROFILE_PATH ~ "/recurring-transactions")
|
||||||
|
void handlePostRecurringTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
|
RecurringTransactionPayload payload = readJsonBodyAs!RecurringTransactionPayload(request);
|
||||||
|
ProfileDataSource ds = getProfileDataSource(request);
|
||||||
|
RecurringTransactionResponse result = createRecurringTransaction(ds, payload);
|
||||||
|
writeJsonBody(response, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping(PROFILE_PATH ~ "/recurring-transactions/:recurringTransactionId:ulong")
|
||||||
|
void handleDeleteRecurringTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
|
ProfileDataSource ds = getProfileDataSource(request);
|
||||||
|
ulong id = getRecurringTransactionId(request);
|
||||||
|
deleteRecurringTransaction(ds, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ulong getRecurringTransactionId(in ServerHttpRequest request) {
|
||||||
|
return getPathParamOrThrow!ulong(request, "recurringTransactionId");
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,3 +67,11 @@ interface TransactionDraftRepository {
|
||||||
void updateTags(ulong draftId, in string[] tags);
|
void updateTags(ulong draftId, in string[] tags);
|
||||||
string[] findAllTags();
|
string[] findAllTags();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RecurringTransactionRepository {
|
||||||
|
Page!RecurringTransactionResponse findAll(in PageRequest pr);
|
||||||
|
RecurringTransactionResponse[] findAllByDraftId(ulong draftId);
|
||||||
|
Optional!RecurringTransactionResponse findById(ulong id);
|
||||||
|
RecurringTransactionResponse insert(in RecurringTransactionPayload data);
|
||||||
|
void deleteById(ulong id);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -913,3 +913,55 @@ class SqliteTransactionDraftRepository : TransactionDraftRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class SqliteRecurringTransactionRepository : RecurringTransactionRepository {
|
||||||
|
private Database db;
|
||||||
|
this(Database db) {
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
Page!RecurringTransactionResponse findAll(in PageRequest pr) {
|
||||||
|
string query = "SELECT * FROM recurring_transaction " ~ pr.toSql();
|
||||||
|
RecurringTransactionResponse[] results = util.sqlite.findAll(db, query, &parseRecurringTransaction);
|
||||||
|
ulong totalCount = util.sqlite.count(db, "SELECT COUNT(DISTINCT id) FROM recurring_transaction");
|
||||||
|
return Page!(RecurringTransactionResponse).of(results, pr, totalCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
RecurringTransactionResponse[] findAllByDraftId(ulong draftId) {
|
||||||
|
string query = "SELECT * FROM recurring_transaction WHERE draft_id = ? ORDER BY id";
|
||||||
|
return util.sqlite.findAll(db, query, &parseRecurringTransaction, draftId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional!RecurringTransactionResponse findById(ulong id) {
|
||||||
|
return util.sqlite.findOne(
|
||||||
|
db,
|
||||||
|
"SELECT * FROM recurring_transaction WHERE id = ?",
|
||||||
|
&parseRecurringTransaction,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
RecurringTransactionResponse insert(in RecurringTransactionPayload data) {
|
||||||
|
util.sqlite.update(
|
||||||
|
db,
|
||||||
|
"INSERT INTO recurring_transaction " ~
|
||||||
|
"(draft_id, schedule_expr) " ~
|
||||||
|
"VALUES (?, ?)",
|
||||||
|
data.draftId,
|
||||||
|
data.scheduleExpr
|
||||||
|
);
|
||||||
|
return findById(db.lastInsertRowid()).orElseThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
void deleteById(ulong id) {
|
||||||
|
util.sqlite.deleteById(db, "recurring_transaction", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RecurringTransactionResponse parseRecurringTransaction(Row row) {
|
||||||
|
return RecurringTransactionResponse(
|
||||||
|
row.peek!ulong(0),
|
||||||
|
row.peek!ulong(1),
|
||||||
|
row.peek!string(2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -205,3 +205,16 @@ struct TransactionDraftPayload {
|
||||||
Optional!ulong categoryId;
|
Optional!ulong categoryId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recurring Transaction stuff:
|
||||||
|
|
||||||
|
struct RecurringTransactionPayload {
|
||||||
|
ulong draftId;
|
||||||
|
string scheduleExpr;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RecurringTransactionResponse {
|
||||||
|
ulong id;
|
||||||
|
ulong draftId;
|
||||||
|
string scheduleExpr;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -530,3 +530,24 @@ private void updateDraftAttachments(
|
||||||
attachmentRepo.remove(idToRemove);
|
attachmentRepo.remove(idToRemove);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recurring Transactions:
|
||||||
|
|
||||||
|
Page!RecurringTransactionResponse getRecurringTransactions(ProfileDataSource ds, PageRequest pr) {
|
||||||
|
return ds.getRecurringTransactionRepository().findAll(pr);
|
||||||
|
}
|
||||||
|
|
||||||
|
RecurringTransactionResponse createRecurringTransaction(ProfileDataSource ds, in RecurringTransactionPayload payload) {
|
||||||
|
return ds.getRecurringTransactionRepository()
|
||||||
|
.insert(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
void deleteRecurringTransaction(ProfileDataSource ds, ulong id) {
|
||||||
|
ds.getRecurringTransactionRepository()
|
||||||
|
.deleteById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
RecurringTransactionResponse[] getRecurringTransactionsForDraft(ProfileDataSource ds, ulong draftId) {
|
||||||
|
return ds.getRecurringTransactionRepository()
|
||||||
|
.findAllByDraftId(draftId);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -225,6 +225,8 @@ CREATE TABLE history_item_linked_journal_entry (
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Drafts / Templates / Recurring Transactions
|
-- Drafts / Templates / Recurring Transactions
|
||||||
|
-- Generally, draft tables are copies of their normal transaction counterparts,
|
||||||
|
-- with looser nullability constraints in some cases.
|
||||||
|
|
||||||
CREATE TABLE transaction_draft (
|
CREATE TABLE transaction_draft (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
"@idle-observer/vue3": "^0.2.0",
|
"@idle-observer/vue3": "^0.2.0",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"chartjs-adapter-date-fns": "^3.0.0",
|
"chartjs-adapter-date-fns": "^3.0.0",
|
||||||
|
"cron-parser": "^5.6.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"date-fns-tz": "^3.2.0",
|
"date-fns-tz": "^3.2.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
|
|
|
||||||
|
|
@ -199,6 +199,17 @@ export interface TransactionDraftPayload {
|
||||||
attachmentIdsToRemove: number[]
|
attachmentIdsToRemove: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RecurringTransactionPayload {
|
||||||
|
draftId: number
|
||||||
|
scheduleExpr: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecurringTransactionResponse {
|
||||||
|
id: number
|
||||||
|
draftId: number
|
||||||
|
scheduleExpr: string
|
||||||
|
}
|
||||||
|
|
||||||
export class TransactionApiClient extends ApiClient {
|
export class TransactionApiClient extends ApiClient {
|
||||||
readonly path: string
|
readonly path: string
|
||||||
|
|
||||||
|
|
@ -385,4 +396,24 @@ export class TransactionApiClient extends ApiClient {
|
||||||
deleteDraft(id: number): Promise<void> {
|
deleteDraft(id: number): Promise<void> {
|
||||||
return super.delete(this.path + '/transaction-drafts/' + id)
|
return super.delete(this.path + '/transaction-drafts/' + id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getRecurringTransactionsForDraft(draftId: number): Promise<RecurringTransactionResponse[]> {
|
||||||
|
return super.getJson(`${this.path}/transaction-drafts/${draftId}/recurring-transactions`)
|
||||||
|
}
|
||||||
|
|
||||||
|
getRecurringTransactions(
|
||||||
|
paginationOptions: PageRequest | undefined = undefined,
|
||||||
|
): Promise<Page<RecurringTransactionResponse>> {
|
||||||
|
return super.getJsonPage(this.path + '/recurring-transactions', paginationOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
createRecurringTransaction(
|
||||||
|
data: RecurringTransactionPayload,
|
||||||
|
): Promise<RecurringTransactionResponse> {
|
||||||
|
return super.postJson(this.path + '/recurring-transactions', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteRecurringTransaction(id: number): Promise<void> {
|
||||||
|
return super.delete(`${this.path}/recurring-transactions/${id}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
TransactionApiClient,
|
||||||
|
type RecurringTransactionPayload,
|
||||||
|
type RecurringTransactionResponse,
|
||||||
|
} from '@/api/transaction'
|
||||||
|
import ModalWrapper from './common/ModalWrapper.vue'
|
||||||
|
import { ref, useTemplateRef, type Ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import AppForm from './common/form/AppForm.vue'
|
||||||
|
import FormGroup from './common/form/FormGroup.vue'
|
||||||
|
import FormControl from './common/form/FormControl.vue'
|
||||||
|
import AppButton from './common/AppButton.vue'
|
||||||
|
import { getSelectedProfile } from '@/api/profile.ts'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const props = defineProps<{ draftId: number }>()
|
||||||
|
const modal = useTemplateRef('modal')
|
||||||
|
const savedTxn: Ref<RecurringTransactionResponse | undefined> = ref()
|
||||||
|
|
||||||
|
// Form Data:
|
||||||
|
const dayOfMonth: Ref<number> = ref(1)
|
||||||
|
|
||||||
|
async function show(): Promise<RecurringTransactionResponse | undefined> {
|
||||||
|
if (!modal.value) return undefined
|
||||||
|
savedTxn.value = undefined
|
||||||
|
const result = await modal.value.show()
|
||||||
|
if (result === 'saved') {
|
||||||
|
return savedTxn.value
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addRecurringTransaction() {
|
||||||
|
const payload: RecurringTransactionPayload = {
|
||||||
|
draftId: props.draftId,
|
||||||
|
scheduleExpr: `0 0 0 ${dayOfMonth.value} * *`,
|
||||||
|
}
|
||||||
|
const api = new TransactionApiClient(getSelectedProfile(route))
|
||||||
|
try {
|
||||||
|
savedTxn.value = await api.createRecurringTransaction(payload)
|
||||||
|
modal.value?.close('saved')
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
savedTxn.value = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ show })
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<ModalWrapper ref="modal">
|
||||||
|
<template v-slot:default>
|
||||||
|
<h2>Add Recurring Transaction</h2>
|
||||||
|
<p>
|
||||||
|
Use this template to add a <em>recurring transaction</em>. According to the interval you
|
||||||
|
specify below, Finnow will create a new draft using this template each time, which you can
|
||||||
|
then review and submit.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Each time a new draft is created from this template for recurring transactions, the new
|
||||||
|
draft's timestamp will be set to the current date and time.
|
||||||
|
</p>
|
||||||
|
<AppForm>
|
||||||
|
<h3>Monthly Schedule</h3>
|
||||||
|
<FormGroup>
|
||||||
|
<FormControl
|
||||||
|
label="Day of Month"
|
||||||
|
hint="Specify on which day of the month this transaction occurs."
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
min="1"
|
||||||
|
max="31"
|
||||||
|
v-model="dayOfMonth"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormGroup>
|
||||||
|
</AppForm>
|
||||||
|
</template>
|
||||||
|
<template v-slot:buttons>
|
||||||
|
<AppButton @click="addRecurringTransaction()">Add</AppButton>
|
||||||
|
<AppButton
|
||||||
|
button-style="secondary"
|
||||||
|
@click="modal?.close()"
|
||||||
|
>Cancel</AppButton
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</ModalWrapper>
|
||||||
|
</template>
|
||||||
|
|
@ -2,25 +2,34 @@
|
||||||
import { ApiError } from '@/api/base'
|
import { ApiError } from '@/api/base'
|
||||||
import { formatMoney } from '@/api/data'
|
import { formatMoney } from '@/api/data'
|
||||||
import { getSelectedProfile } from '@/api/profile'
|
import { getSelectedProfile } from '@/api/profile'
|
||||||
import { TransactionApiClient, type TransactionDraftResponse } from '@/api/transaction'
|
import {
|
||||||
|
TransactionApiClient,
|
||||||
|
type RecurringTransactionResponse,
|
||||||
|
type TransactionDraftResponse,
|
||||||
|
} from '@/api/transaction'
|
||||||
import AppButton from '@/components/common/AppButton.vue'
|
import AppButton from '@/components/common/AppButton.vue'
|
||||||
import AppPage from '@/components/common/AppPage.vue'
|
import AppPage from '@/components/common/AppPage.vue'
|
||||||
import CategoryLabel from '@/components/CategoryLabel.vue'
|
import CategoryLabel from '@/components/CategoryLabel.vue'
|
||||||
import PropertiesTable from '@/components/PropertiesTable.vue'
|
import PropertiesTable from '@/components/PropertiesTable.vue'
|
||||||
import TagLabel from '@/components/TagLabel.vue'
|
import TagLabel from '@/components/TagLabel.vue'
|
||||||
import { showAlert, showConfirm } from '@/util/alert'
|
import { showAlert, showConfirm } from '@/util/alert'
|
||||||
import { computed, onMounted, ref, type Ref } from 'vue'
|
import { computed, onMounted, ref, useTemplateRef, type Ref } from 'vue'
|
||||||
import AttachmentRow from '@/components/common/AttachmentRow.vue'
|
import AttachmentRow from '@/components/common/AttachmentRow.vue'
|
||||||
import LineItemCard from '@/components/LineItemCard.vue'
|
import LineItemCard from '@/components/LineItemCard.vue'
|
||||||
import AppBadge from '@/components/common/AppBadge.vue'
|
import AppBadge from '@/components/common/AppBadge.vue'
|
||||||
import ButtonBar from '@/components/common/ButtonBar.vue'
|
import ButtonBar from '@/components/common/ButtonBar.vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import AddRecurringTransactionModal from '@/components/AddRecurringTransactionModal.vue'
|
||||||
|
import { CronExpressionParser } from 'cron-parser'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const transactionApi = new TransactionApiClient(getSelectedProfile(route))
|
const transactionApi = new TransactionApiClient(getSelectedProfile(route))
|
||||||
|
const addRecurringTransactionModal = useTemplateRef('addRecurringTransactionModal')
|
||||||
|
|
||||||
const draft: Ref<TransactionDraftResponse | undefined> = ref()
|
const draft: Ref<TransactionDraftResponse | undefined> = ref()
|
||||||
|
const recurringTransactions: Ref<RecurringTransactionResponse[]> = ref([])
|
||||||
|
|
||||||
const pageTitle = computed(() => {
|
const pageTitle = computed(() => {
|
||||||
if (draft.value === undefined) return 'Transaction Draft'
|
if (draft.value === undefined) return 'Transaction Draft'
|
||||||
if (draft.value.templateName !== null && draft.value.templateName.length > 0) {
|
if (draft.value.templateName !== null && draft.value.templateName.length > 0) {
|
||||||
|
|
@ -31,8 +40,10 @@ const pageTitle = computed(() => {
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const draftId = parseInt(route.params.id as string)
|
const draftId = parseInt(route.params.id as string)
|
||||||
|
recurringTransactions.value = []
|
||||||
try {
|
try {
|
||||||
draft.value = await transactionApi.getDraft(draftId)
|
draft.value = await transactionApi.getDraft(draftId)
|
||||||
|
recurringTransactions.value = await transactionApi.getRecurringTransactionsForDraft(draftId)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
await router.replace('/')
|
await router.replace('/')
|
||||||
|
|
@ -61,6 +72,37 @@ async function onVendorClicked() {
|
||||||
await router.push(`/profiles/${getSelectedProfile(route)}/vendors/${draft.value.vendor.id}`)
|
await router.push(`/profiles/${getSelectedProfile(route)}/vendors/${draft.value.vendor.id}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function addRecurringTransaction() {
|
||||||
|
const result = await addRecurringTransactionModal.value?.show()
|
||||||
|
if (result !== undefined && draft.value) {
|
||||||
|
recurringTransactions.value = await transactionApi.getRecurringTransactionsForDraft(
|
||||||
|
draft.value.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteRecurringTransaction(rt: RecurringTransactionResponse) {
|
||||||
|
if (!draft.value) return
|
||||||
|
await transactionApi.deleteRecurringTransaction(rt.id)
|
||||||
|
recurringTransactions.value = await transactionApi.getRecurringTransactionsForDraft(
|
||||||
|
draft.value.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRecurringTransactionScheduleExpr(rt: RecurringTransactionResponse) {
|
||||||
|
const interval = CronExpressionParser.parse(rt.scheduleExpr)
|
||||||
|
const dayOfMonth = interval.fields.dayOfMonth.serialize().values[0] as number
|
||||||
|
return `The ${dayOfMonth}${getNumberAdjectiveSuffix(dayOfMonth)} day of every month`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNumberAdjectiveSuffix(n: number) {
|
||||||
|
const digit = n % 10
|
||||||
|
if (digit === 1) return 'st'
|
||||||
|
if (digit === 2) return 'nd'
|
||||||
|
if (digit === 3) return 'rd'
|
||||||
|
return 'th'
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<AppPage
|
<AppPage
|
||||||
|
|
@ -184,5 +226,35 @@ async function onVendorClicked() {
|
||||||
>Delete</AppButton
|
>Delete</AppButton
|
||||||
>
|
>
|
||||||
</ButtonBar>
|
</ButtonBar>
|
||||||
|
<ButtonBar>
|
||||||
|
<AppButton
|
||||||
|
icon="repeat"
|
||||||
|
@click="addRecurringTransaction()"
|
||||||
|
>
|
||||||
|
Add Recurring Transaction
|
||||||
|
</AppButton>
|
||||||
|
</ButtonBar>
|
||||||
|
|
||||||
|
<div v-if="recurringTransactions.length > 0">
|
||||||
|
<h3>Recurring Transactions</h3>
|
||||||
|
<p>This template will be used to create new drafts using the following schedules:</p>
|
||||||
|
<ul>
|
||||||
|
<li
|
||||||
|
v-for="rt in recurringTransactions"
|
||||||
|
:key="rt.id"
|
||||||
|
>
|
||||||
|
{{ formatRecurringTransactionScheduleExpr(rt) }}
|
||||||
|
<AppButton
|
||||||
|
icon="trash"
|
||||||
|
@click="deleteRecurringTransaction(rt)"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AddRecurringTransactionModal
|
||||||
|
ref="addRecurringTransactionModal"
|
||||||
|
:draft-id="draft.id"
|
||||||
|
/>
|
||||||
</AppPage>
|
</AppPage>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue