Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add distribution strategies #76

Merged
merged 3 commits into from
Jun 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions my-app/src/lib/formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,13 @@ export function fixDateString(dateString: string, timezoneOffset: number) {
}

export function formatMoney(amount: number) {
const fixed = amount
.toFixed(2)
.replace('.', ',')
.replace(/\d(?=(\d{3})+,)/g, '$&.');
return `$ ${fixed}`;
try {
const fixed = amount
.toFixed(2)
.replace('.', ',')
.replace(/\d(?=(\d{3})+,)/g, '$&.');
return `$ ${fixed}`;
} catch (error) {
return '-';
}
}
6 changes: 6 additions & 0 deletions my-app/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,9 @@ export function getUserEmailById(users: User[], id: number): User['email'] {

/// When a budget is greater or equal to this threshold, it is considered to be near its limit.
export const BUDGET_NEAR_LIMIT_THRESHOLD = 0.9;

export const strategies: Record<Strategy, string> = {
equalparts: 'En partes iguales',
percentage: 'En porcentajes',
custom: 'Manual'
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@ import { error, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
import { categoryService, groupService } from '$lib/server/api';
import type { PageServerLoad } from './$types';
import { routes } from '$lib';
import { routes, strategies } from '$lib';

export const load: PageServerLoad = async ({ params, url, cookies }) => {
const group_id = Number(url.searchParams.get('groupId')) || 0;
const id = Number(params.id) || 0;
const category: Category = id
? await categoryService.get(id, cookies)
: { id: 0, group_id, name: '', description: '', strategy: '' };
: { id: 0, group_id, name: '', description: '', strategy: 'equalparts' };
const groups: Group[] = await groupService.list(cookies);
return { category, groups };
};
Expand All @@ -21,7 +21,7 @@ export const actions: Actions = {
const name = data.get('name')?.toString();
const description = data.get('description')?.toString();
const group_id = Number(data.get('groupId'));
const strategy = '';
const strategyData = data.get('stategy')?.toString();

if (!name) {
throw error(400, 'Name is required');
Expand All @@ -32,6 +32,11 @@ export const actions: Actions = {
if (!group_id) {
throw error(400, 'Group is required');
}
if (!strategyData || !Object.keys(strategies).includes(strategyData)) {
throw error(400, 'Strategy is required');
}

const strategy = strategyData as Strategy;

const category: Category = { id, group_id, name, description, strategy };
await categoryService.save(category, cookies);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { routes, title } from '$lib';
import { routes, strategies, title } from '$lib';
import CssIcon from '$lib/components/CssIcon.svelte';
import type { PageData } from './$types';

Expand Down Expand Up @@ -55,6 +55,14 @@
value={data.category.description}
/>
</label>
<label>
Elija una estrategia de distribución de gastos por default
<select name="stategy" value={data.category.strategy}>
{#each Object.entries(strategies) as [strategy, description]}
<option value={strategy}>{description}</option>
{/each}
</select>
</label>
{#if edit}
{#if isArchived}
<button class="contrast" formaction="?/unarchive">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export const actions: Actions = {
group_id: id,
name: 'Sin categorizar',
description: '',
strategy: ''
strategy: 'equalparts'
},
cookies
);
Expand Down
45 changes: 33 additions & 12 deletions my-app/src/routes/spendings/details/[[id=integer]]/+page.server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { error, redirect } from '@sveltejs/kit';
import { error, redirect, type Cookies } from '@sveltejs/kit';
import type { Actions } from './$types';
import { groupService, spendingService } from '$lib/server/api';
import type { PageServerLoad } from './$types';
Expand All @@ -17,7 +17,8 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
owner_id: 0,
date: new Date().toJSON(),
category_id: 0,
group_id
group_id,
strategy_data: []
};
const groups: Group[] = await groupService.list(cookies);
return { spending, groups };
Expand All @@ -36,6 +37,8 @@ export const actions: Actions = {
const type = Number(data.get('type'));
const amount_of_installments = Number(data.get('amountOfInstallments'));

const distribution: Distribution[] = getDistribution(data);

if (!description) {
throw error(400, 'Description is required');
}
Expand All @@ -55,22 +58,40 @@ export const actions: Actions = {
date,
group_id,
category_id,
owner_id
owner_id,
strategy_data: distribution
};

try {
await (type === spendingType.unique
? spendingService.saveUniqueSpending(spending, cookies)
: type === spendingType.installment
? spendingService.saveInstallmentSpending(
{ ...spending, amount_of_installments },
cookies
)
: spendingService.saveRecurringSpending(spending, cookies));
} catch {
await save(cookies, spending, type, amount_of_installments);
} catch (err) {
return { success: false };
}

redirect(302, routes.groupMovements(group_id));
}
};

function getDistribution(data: FormData) {
const distribution: Distribution[] = [];
let i = 0;
while (data.get(`distribution[${i}].value`)) {
distribution.push({
user_id: Number(data.get(`distribution[${i}].user_id`)),
value: Number(data.get(`distribution[${i}].value`))
});
i++;
}
return distribution;
}

function save(cookies: Cookies, spending: Spending, type: number, amount_of_installments: number) {
if (type === spendingType.installment) {
const installmentSpending: InstallmentSpending = { ...spending, amount_of_installments };
return spendingService.saveInstallmentSpending(installmentSpending, cookies);
}
if (type === spendingType.recurring) {
return spendingService.saveRecurringSpending(spending, cookies);
}
return spendingService.saveUniqueSpending(spending, cookies);
}
119 changes: 111 additions & 8 deletions my-app/src/routes/spendings/details/[[id=integer]]/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,20 +1,33 @@
<script lang="ts">
import { routes, title } from '$lib';
import { routes, strategies, title } from '$lib';
import { onMount } from 'svelte';
import type { PageServerData } from './$types';
import type { ActionData, PageData } from './$types';
import { fixDateString, formatMoney } from '$lib/formatter';
import { spendingType } from './spending-utils';
import Avatar from '$lib/components/Avatar.svelte';

export let data: PageServerData;
export let form: ActionData;
export let data: PageData;
let timezoneOffset = 0;
let suggestions: Map<string, Spending> = new Map();
let categories: Category[] = [];
let members: User[] = [];
let distribution: Distribution[] = [];
let type = spendingType.unique;
let strategy: Strategy;

$: isInstallment = type === spendingType.installment;
$: strategy =
categories.find((c) => c.id === data.spending.category_id)?.strategy || 'equalparts';
$: distributionValueText = strategy === 'percentage' ? 'Porcentaje' : 'Monto';
$: total = sum(distribution.map((d) => getSubtotal(d.value)));

async function onGroupUpdate(groupId: number) {
await Promise.all([updateSuggestions(groupId), updateCategories(groupId)]);
await Promise.all([
updateSuggestions(groupId),
updateCategories(groupId),
updaterMembers(groupId)
]);
}

async function updateSuggestions(groupId: number) {
Expand All @@ -26,7 +39,9 @@
body.forEach((spending) => {
newSuggestions.set(spending.description, spending);
});
} catch {}
} catch {
//
}
}
suggestions = newSuggestions;
}
Expand All @@ -38,11 +53,28 @@
categories = await response.json();
categories = categories.filter((category) => !category.is_archived);
return;
} catch {}
} catch {
//
}
}
categories = [];
}

async function updaterMembers(groupId: number) {
if (groupId != 0) {
try {
const response = await fetch(`${routes.apiMembers}?groupId=${groupId}`);
members = await response.json();
distribution = members.map(({ id: user_id }) => ({ user_id, value: 0 }));
return;
} catch {
//
}
}
members = [];
distribution = [];
}

function autocomplete(value: string) {
const spending = suggestions.get(value);
if (!spending) return;
Expand All @@ -51,6 +83,17 @@
data.spending.category_id = spending.category_id;
}

function getSubtotal(aValue: number) {
if (strategy === 'percentage') {
return (aValue / 100) * data.spending.amount;
}
return aValue;
}

function sum(values: number[]) {
return values.reduce((acc, value) => acc + value, 0);
}

onMount(async () => {
timezoneOffset = new Date().getTimezoneOffset();
data.spending.date = fixDateString(data.spending.date, timezoneOffset).slice(0, 16);
Expand All @@ -62,6 +105,10 @@
<title>{title} - Nuevo Gasto Unico</title>
</svelte:head>

{#if form && !form.success}
Ocurrió un error
{/if}

<h2>Nuevo Gasto</h2>
<form method="POST" autocomplete="off">
<fieldset>
Expand All @@ -81,7 +128,7 @@
</label>
<label>
Ingrese la categoría a la que pertenece el gasto
<select name="categoryId" required value={data.spending.category_id}>
<select name="categoryId" required bind:value={data.spending.category_id}>
{#each categories as category}
<option value={category.id}>{category.name}</option>
{/each}
Expand Down Expand Up @@ -118,7 +165,13 @@
</fieldset>
<label>
Ingrese un monto para {isInstallment ? 'las cuotas' : 'el gasto'}
<input type="text" name="amount" placeholder="Monto" required value={data.spending.amount} />
<input
type="text"
name="amount"
placeholder="Monto"
required
bind:value={data.spending.amount}
/>
</label>
{#if isInstallment}
<label>
Expand All @@ -131,6 +184,56 @@
/>
</label>
{/if}
{#if strategy}
<label>
Estrategia
<input type="text" readonly value={strategies[strategy]} />
</label>
{#if strategy === 'percentage' || strategy === 'custom'}
<table>
<thead>
<tr>
<th>Miembro</th>
<th>{distributionValueText}</th>
<th class="t-right">Subtotal</th>
</tr>
</thead>
<tbody>
{#each members as member, i}
{@const subtotal = getSubtotal(distribution[i].value)}
<tr>
<td>
<Avatar seed={member.email} size={40} />
{member.email}
</td>
<td>
<input
class="t-right"
type="number"
name="distribution[{i}].value"
placeholder={distributionValueText}
bind:value={distribution[i].value}
/>
<input type="hidden" name="distribution[{i}].user_id" value={member.id} />
</td>
<td class="t-right {subtotal > data.spending.amount ? 'invalid' : ''}">
{formatMoney(subtotal)}
</td>
</tr>
{/each}
</tbody>
<tfoot>
<tr>
<th>Total</th>
<td class="t-right">{sum(distribution.map((d) => d.value))}</td>
<td class="t-right {total !== data.spending.amount ? 'invalid' : ''}">
{formatMoney(total)}
</td>
</tr>
</tfoot>
</table>
{/if}
{/if}
<label>
Fecha del gasto
<input
Expand Down
Loading