E2EE Group Finance Management
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

444 lines
12 KiB

<style>
:root {
--red: #cc4c47;
--green: #d3e29d;
--c1: #bcbcbc;
--c2: #2b2b2b;
--c3: #b0b0b0;
--c4: #444;
--c5: #fcfcfc;
}
:global(body) {
background: var(--c5);
font-family: Verdana, Geneva, Tahoma, sans-serif;
}
:global(button) {
margin: 0.5em 0.25em;
padding: 0.5em 2em;
border: 0;
background-color: var(--c2);
color: var(--c5);
font-weight: 700;
border-radius: 5px;
cursor: pointer;
transition: all 40ms linear;
box-shadow: -3px 3px var(--c1);
}
:global(button:hover) {
box-shadow: -2px 2px var(--c1);
transform: translate(-1px, 1px);
}
:global(button:active) {
box-shadow: none;
transform: translate(-3px, 3px);
}
:global(*:focus) {
outline: 3px solid greenyellow;
}
:global(button.danger) {
background: var(--red);
}
:global(.noselect, label) {
-webkit-touch-callout: none; /* iOS Safari */
-webkit-user-select: none; /* Safari */
-khtml-user-select: none; /* Konqueror HTML */
-moz-user-select: none; /* Old versions of Firefox */
-ms-user-select: none; /* Internet Explorer/Edge */
user-select: none; /* Non-prefixed version, currently supported by Chrome, Edge, Opera and Firefox */
}
:global(form) {
border-radius: 5px;
padding: 1em;
background-color: #fff;
min-width: 500px;
display: flex;
flex-direction: column;
}
:global(form label, form legend) {
margin-top: 0.5em;
font-size: 0.7em;
text-transform: capitalize;
color: var(--c3);
}
:global(form h1) {
text-align: center;
color: var(--c4);
margin: 0 0 0.25em 0;
}
:global(input, select) {
margin: 0em;
padding: 0.75em 0.5em;
border: 1px solid var(--c3);
border-radius: 5px;
background-color: var(--c5);
}
:global(fieldset) {
padding: 0.25em;
border: 1px solid var(--c3);
border-radius: 5px;
}
.flexbox {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
}
.debug {
display: flex;
flex-direction: row;
align-self: center;
justify-content: center;
margin: 1em;
}
header.transaction-list h2 {
text-align: center;
text-decoration: underline;
text-decoration-color: var(--c1);
text-decoration-thickness: 1px;
font-weight: 400;
color: var(--c4);
font-size: 1.2em;
}
header.transaction-list select {
background-color: var(--c5);
border-radius: 5px;
border: 1px solid var(--c3);
}
header.transaction-list p {
text-align: center;
}
</style>
<script context="module">
import "rxdb/dist/rxdb.browserify.js";
declare const window: any;
const rxdb = window.RxDB;
</script>
<script lang="typescript">
// Type Imports
import type { RxDatabase, RxDocument } from "rxdb";
import type { ITransaction } from "./types/transaction.types";
import type { Member } from "./types/member.type";
// Class Imports
import { MemberTransaction, Transaction } from "./classes/transaction.classes";
// RxDB Schema Imports
import { TransactionSchema } from "./schemas/transaction.schema";
import { MemberSchema } from "./schemas/member.schema";
// Svelte Core Imports
import { onMount } from "svelte";
import { toast, currentMember } from "./stores";
// Svelte Components
import TransactionView from "./Components/Transaction/TransactionView.svelte";
import TransactionCreator from "./Components/Transaction/TransactionCreator.svelte";
import MemberCreator from "./Components/MemberCreator.svelte";
import Modal from "./Components/Modal.svelte";
import Dialog from "./Components/Dialog.svelte";
import Toast from "./Components/Toast.svelte";
type ModalProp = {
content: any;
props: any | undefined;
primaryAction: any | undefined;
};
let db: RxDatabase;
let transactions: Array<ITransaction> = [];
let concreteTransactions: Array<Transaction> = [];
let members: Array<Member> = [];
let modalFunction: ModalProp | undefined = undefined;
let toastHistory: Array<string> = [];
onMount(async () => {
toast.subscribe(x => {
if (x.content) {
toastHistory.push(x.content || "");
toastHistory = toastHistory;
}
});
await createDatabase();
});
const createDatabase = async () => {
db = await rxdb.createRxDatabase({
name: "myfin",
adapter: "idb",
password: "BadPassword",
});
await db.addCollections({
transactions: { schema: TransactionSchema },
members: { schema: MemberSchema },
});
await db.transactions.insert$.subscribe((change: unknown) =>
updateTransactions(),
);
await db.members.insert$.subscribe((change: unknown) => updateMembers());
await updateMembers();
await updateTransactions();
let firstMemberId = members.map(x => x.id)[0];
$currentMember = firstMemberId;
};
const nukeDatabase = async () => {
await db.remove();
await createDatabase();
};
const updateTransactions = async () => {
transactions = await db.transactions.find().exec();
let newConcreteTransactions = [];
for (let index = 0; index < transactions.length; index++) {
const element = transactions[index];
newConcreteTransactions.push(await createConcreteTransaction(element));
}
concreteTransactions = newConcreteTransactions;
};
const createConcreteTransaction = async (transaction: ITransaction) => {
let credits: Array<MemberTransaction> = [];
for (let index = 0; index < transaction.credits.length; index++) {
const memberTransaction = transaction.credits[index];
let member: Member = await getMemberFromId(memberTransaction.id);
credits.push(
new MemberTransaction(
member.id,
member.name,
memberTransaction.amount,
),
);
}
let debits: Array<MemberTransaction> = [];
for (let index = 0; index < transaction.debits.length; index++) {
const memberTransaction = transaction.debits[index];
let member: Member = await getMemberFromId(memberTransaction.id);
debits.push(
new MemberTransaction(
member.id,
member.name,
memberTransaction.amount,
),
);
}
return new Transaction(
transaction.id,
transaction.date,
transaction.currency,
transaction.title,
credits,
debits,
);
};
const updateMembers = async () => {
members = await db.members.find().exec();
};
const getMemberFromId = async (id: string) => {
return await db.members.findOne().where("id").eq(id).exec();
};
const getMemberBalance = async (id: string) => {
let balance = 0;
for (let index = 0; index < concreteTransactions.length; index++) {
const transaction = concreteTransactions[index];
balance += transaction.impactOnMember(id);
}
return balance;
};
const currencyFormatter = new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 2,
});
const handleNewTransactionEvent = async (event: any) => {
let newTransaction: Transaction = event.detail.transaction;
await db.transactions.insert(newTransaction);
await updateTransactions();
toast.show(`🤑 Added Transaction: ${newTransaction.title} 🤑`, "created");
};
const handleNewMemberEvent = async (event: any) => {
if (event.detail.name) {
let newMember: Member = {
id: (Date.now() + Math.random()).toString(),
name: event.detail.name,
};
await db.members.insert(newMember);
toast.show(`🎉 Added Member: ${newMember.name}! 🎉`, "created");
}
};
const handleEditTransactionEvent = async (event: any) => {
let updatedTransaction: Transaction = event.detail.transaction;
let existingDoc = await db.transactions
.findOne({ selector: { id: updatedTransaction.id } })
.exec();
existingDoc.atomicPatch(updatedTransaction);
await updateTransactions();
toast.show(
`🤑 Updated Transaction: ${updatedTransaction.title} 🤑`,
"updated",
);
};
const handleDeleteTransactionEvent = async (event: any) => {
let transaction: Transaction = event.detail.transaction;
let existingDoc: RxDocument = await db.transactions
.findOne({ selector: { id: transaction.id } })
.exec();
existingDoc.remove();
await updateTransactions();
toast.show(`🔥 Removed Transaction: ${transaction.title} 🔥`, "removed");
};
</script>
<Toast />
{#if modalFunction}
<Modal
on:Close="{() => {
modalFunction = undefined;
}}"
>
<svelte:component
this="{modalFunction.content}"
{...modalFunction.props}
on:Submitted="{async event => {
await modalFunction?.primaryAction(event);
modalFunction = undefined;
}}"
/>
</Modal>
{/if}
<section>
<nav class="debug">
<button
on:click="{() => {
modalFunction = {
content: Dialog,
primaryAction: nukeDatabase,
props: {
content:
'🔥 Are you sure you want to <strong>nuke</strong> the database? 🔥',
primaryButtonText: 'YES, BURN IT ALL',
dangerous: true,
},
};
}}"
class="danger">Nuke</button
>
<button
on:click="{() => {
modalFunction = {
content: MemberCreator,
primaryAction: handleNewMemberEvent,
props: undefined,
};
}}">Add Member</button
>
<button
on:click="{() => {
console.log($currentMember);
modalFunction = {
content: TransactionCreator,
primaryAction: handleNewTransactionEvent,
props: {
members: members,
currencyFormatter: currencyFormatter,
defaultCreditor: $currentMember,
},
};
}}">Add Transaction</button
>
</nav>
<header class="transaction-list">
<h2>Showing <strong>{transactions.length}</strong> transactions</h2>
<p>
Viewing as:
<select bind:value="{$currentMember}">
{#each members as member}
<option value="{member.id}">{member.name}</option>
{/each}
</select>
</p>
</header>
<div class="flexbox">
{#each concreteTransactions as transaction (transaction.id)}
<TransactionView
currencyFormatter="{currencyFormatter}"
transaction="{transaction}"
viewingMemberId="{$currentMember}"
on:Edit="{event => {
modalFunction = {
content: TransactionCreator,
primaryAction: handleEditTransactionEvent,
props: {
members: members,
currencyFormatter: currencyFormatter,
defaultCreditor: $currentMember,
existingTransaction: event.detail.transaction,
},
};
}}"
on:Delete="{handleDeleteTransactionEvent}"
/>
{/each}
</div>
{#if toastHistory.length > 0}
<div>
<h3>Toast History</h3>
<ul>
{#each toastHistory as t}
<li>{t}</li>
{/each}
</ul>
</div>
{/if}
</section>