Skip to content

Commit

Permalink
Transaction label feature added
Browse files Browse the repository at this point in the history
  • Loading branch information
Guillaume_Bernier committed Dec 8, 2024
1 parent 6e2d4ff commit 2362c5f
Show file tree
Hide file tree
Showing 9 changed files with 457 additions and 28 deletions.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ Release v0.1
- [x] Download document
- [x] Date filter
### Transaction label
- [ ] Create
- [ ] Delete (default to general label)
- [X] Create
- [X] Edit
- [X] Delete (default to general label)
- [ ] Assign
### Dashboard
- [X] Display revenues/expenses
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import {
currentSelectedTransaction,
isDialogOpen
isTransactionDialogOpen
} from '../../../../../routes/(app)/admin/transactions/store';
export let row: Transactions;
Expand Down Expand Up @@ -39,7 +39,7 @@
function editTransaction() {
return () => {
currentSelectedTransaction.set(transaction.id);
isDialogOpen.set(true);
isTransactionDialogOpen.set(true);
};
}
</script>
Expand Down
80 changes: 80 additions & 0 deletions app/src/lib/utils/transactions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import type PocketBase from 'pocketbase';

interface Locals {
pb: PocketBase;
}

export function formatDate(dateString: string): string {
return new Date(dateString).toISOString().split('T')[0];
}

/**
* Fetches transaction data for a user, either from cache or by querying the database.
* @param {Locals} locals - The locals object containing PocketBase and Redis instances.
* @param {string} userId - The ID of the user to fetch transactions for.
* @param {string|null} startDate - The start date for filtering transactions (optional).
* @param {string|null} endDate - The end date for filtering transactions (optional).
* @returns {Promise<any>} The fetched transaction data.
*/
export async function fetchTransactionData(locals: Locals, userId: string, startDate: string | null, endDate: string | null) {

let fetchFunction: (locals: Locals, userId: string, startDate: string | null, endDate: string | null) => Promise<any>;

if (startDate && endDate) {
fetchFunction = fetchDataByDateRange;
} else {
fetchFunction = fetchLastTransactions;
}

let data = await fetchFunction(locals, userId, startDate, endDate);

return data;
}

async function fetchLastTransactions(locals: Locals, userId: string) {
const transactions = await locals.pb.collection('transactions').getList(1, 25, {
sort: '-created',
fields: 'id,title,date,label,type,document,amount,receiptNumber,currency,pdf',
filter: `userId="${userId}"`,
});

return fetchAdditionalData(locals, userId, transactions.items);
}

async function fetchDataByDateRange(locals: Locals, userId: string, startDate: string | null, endDate: string | null) {
if (!startDate || !endDate) {
throw new Error('Start date and end date are required for date range filtering');
}

const transactions = await locals.pb.collection('transactions').getFullList({
sort: '-created',
fields: 'id,title,date,label,type,document,amount,receiptNumber,currency,pdf',
filter: `userId="${userId}" && date >= "${startDate}" && date <= "${endDate}"`,
});

return fetchAdditionalData(locals, userId, transactions);
}

async function fetchAdditionalData(locals: Locals, userId: string, transactions: any[]) {
const labels = await locals.pb.collection('labels').getFullList({
sort: 'name',
filter: `userId="${userId}"`
});

const transactionLabels = await locals.pb.collection('transactionLabels').getFullList({
filter: `userId="${userId}"`
});

const formattedTransactions = transactions.map(transaction => ({
...transaction,
date: formatDate(transaction.date)
}));

return {
props: {
transactions: formattedTransactions,
labels,
transactionLabels
}
};
}
126 changes: 115 additions & 11 deletions app/src/routes/(app)/admin/transactions/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,24 @@ import { superValidate } from "sveltekit-superforms";
import { zod } from "sveltekit-superforms/adapters";
import { type Actions, fail, redirect } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types.js";
import { formSchema } from "./schema";
import { formSchema, labelSchema } from "./schema";
import { fetchTransactionData } from "$lib/utils/transactions.js";

export const load: PageServerLoad = async ({ locals }) => {
export const load: PageServerLoad = async ({ locals, url }) => {
if (!locals.pb.authStore.model) {
throw new Error('User not authenticated');
}

const userId = locals.pb.authStore.model.id;
const startDate = url.searchParams.get('startDate');
const endDate = url.searchParams.get('endDate');

// Add pagination later
const records = await locals.pb.collection('transactions').getFullList({
sort: '-created',
fields: 'id,title,date,label,type,document,amount,receiptNumber, currency'
});
const data = await fetchTransactionData(locals, userId, startDate, endDate);

return {
props: {
transactions: records
},
form: await superValidate(zod(formSchema))
...data,
form: await superValidate(zod(formSchema)),
labelForm: await superValidate(zod(labelSchema))
};
};

Expand Down Expand Up @@ -136,5 +139,106 @@ export const actions: Actions = {
} catch (error) {
console.error("Failed to download the transaction", error);
}
},
createLabel: async ({ request, locals }) => {
const formData = await request.formData();
const form = await superValidate(formData, zod(labelSchema));
if (!form.valid) {
return fail(400, { form });
}

if (!locals.pb.authStore.model) {
return fail(401, { message: 'No authenticated user found.' });
}

const userId = locals.pb.authStore.model.id;

try {
const data = {
userId,
name: form.data.name,
color: form.data.color
};

await locals.pb.collection('labels').create(data);

return { status: 200, body: 'Label created successfully.' };
} catch (err) {
console.log('Error on create label: ', err);
return fail(500, { error: 'Failed to create label' });
}
},
editLabel: async ({ request, locals }) => {
const formData = await request.formData();
const form = await superValidate(formData, zod(labelSchema));
if (!form.valid) {
return fail(400, { form });
}

if (!locals.pb.authStore.model) {
return fail(401, { message: 'No authenticated user found.' });
}

const userId = locals.pb.authStore.model.id;

try {
const existingLabel = await locals.pb.collection('labels').getOne(form.data.id as string);
if (existingLabel.userId !== userId) {
return fail(403, { message: 'You do not have permission to edit this label.' });
}

const updatedLabel = await locals.pb.collection('labels').update(form.data.id as string, {
name: form.data.name,
color: form.data.color
});


return { status: 200, body: 'Label updated successfully.' };
} catch (err) {
console.log('Error on edit label: ', err);
return fail(500, { error: 'Failed to edit label' });
}
},
deleteLabel: async ({ request, locals }) => {
const formData = await request.formData();
const labelId = formData.get('id');

if (typeof labelId !== 'string' || !labelId) {
return fail(400, { error: 'Invalid or missing label ID.' });
}

if (!locals.pb.authStore.model) {
return fail(401, { message: 'No authenticated user found.' });
}

const userId = locals.pb.authStore.model.id;

try {
const existingLabel = await locals.pb.collection('labels').getOne(labelId);
if (existingLabel.userId !== userId) {
return fail(403, { message: 'You do not have permission to delete this label.' });
}

// First, remove the label from all transactions
const transactionLabels = await locals.pb.collection('transactionLabels').getFullList({
filter: `labelId="${labelId}" && userId="${userId}"`
});

for (const label of transactionLabels) {
try {
await locals.pb.collection('transactionLabels').delete(label.id);
} catch (labelError) {
console.error(`Error deleting associated label ${label.id}:`, labelError);
}
}

// Then delete the label
await locals.pb.collection('labels').delete(labelId);

return { status: 200, body: 'Label deleted successfully.' };
} catch (err) {
console.log('Error on delete label: ', err);
return fail(500, { error: 'Failed to delete label' });
}
}
}
58 changes: 57 additions & 1 deletion app/src/routes/(app)/admin/transactions/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,72 @@
import type { PageData } from './$types.js';
import DataTable from '$lib/components/ui/data-table/transactions/data-table.svelte';
import TransactionForm from './transaction-form.svelte';
import LabelForm from './label-form.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import { Settings, ChevronDown } from 'lucide-svelte';
import { isTransactionDialogOpen, isLabelDialogOpen, labelDialogState } from './store.js';
export let data: PageData;
let formData: any = data.form;
let labelFormData: any = data.labelForm;
let labelData: any = data.props.labels;
let transactionData: any = data.props.transactions;
function labelDialogStates(state: string) {
if (state === 'edit') {
$labelDialogState = 'edit';
} else if (state === 'delete') {
$labelDialogState = 'delete';
} else if (state === 'create') {
$labelDialogState = 'create';
}
$isLabelDialogOpen = true;
}
</script>

<div class=" h-full flex-1 flex-col space-y-8 p-8 md:flex">
<!-- Maybe I should add the selected row in the store in data-table-row-actions instead of passing all the transactions here -->
<TransactionForm data={formData} createdTransactions={transactionData} />
<LabelForm data={labelFormData} labels={labelData} />
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<h1 class="text-3xl font-bold tracking-tight">Transactions</h1>
</div>
<div class="flex items-center gap-2">
<Button class="hidden md:flex" on:click={() => ($isTransactionDialogOpen = true)}>
Add Transaction
</Button>
<Button class="h-[40px] pr-0" variant="outline" on:click={() => labelDialogStates('create')}>
Add Label
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild let:builder>
<Button
class="ml-4 border-0"
builders={[builder]}
variant="outline"
size="icon"
on:click={(e) => {
e.stopPropagation();
}}
>
<ChevronDown class="h-4 w-4" />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content class="w-46" align="start">
<DropdownMenu.Group>
<DropdownMenu.Item on:click={() => labelDialogStates('edit')}>
Edit Label
</DropdownMenu.Item>
<DropdownMenu.Item on:click={() => labelDialogStates('delete')}>
Delete Label
</DropdownMenu.Item>
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Button>
</div>
</div>
<DataTable data={transactionData} />
</div>
Loading

0 comments on commit 2362c5f

Please sign in to comment.